Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f647cd6
docs: restructure the roadmap around the one-way-door test
cemililik Aug 8, 2026
7757c68
docs: close the verification findings from the restructure
cemililik Aug 8, 2026
aec9cde
docs(roadmap): annotate the frozen phases with what changed after them
cemililik Aug 8, 2026
dfbd29d
docs(roadmap): give Phase 02d the two CI jobs Phase 01's note points at
cemililik Aug 8, 2026
1e7114c
fix(skills): correct the RLS template where agents actually execute it
cemililik Aug 8, 2026
ceaad39
fix(rls): three PostgreSQL defects in the canonical isolation template
cemililik Aug 8, 2026
0cce302
fix(audit): settle the durability model on the transaction, not on Sa…
cemililik Aug 9, 2026
31cd53e
fix(tenancy): settle the slug namespace and the table-class × role × …
cemililik Aug 9, 2026
3e21867
fix(corpus): close the 23 defects the numbered findings never got a n…
cemililik Aug 9, 2026
1434e8f
fix(hub-contract): give the entitlement projection its grace and gene…
cemililik Aug 9, 2026
b9ac78c
fix(rls): put the session variables where the transaction is, and mak…
cemililik Aug 9, 2026
7446233
fix(entitlement): reserve "entitlement" for the Hub, and take the rev…
cemililik Aug 9, 2026
1d4c7cb
fix(demand-gating): make ADR-0035's own drivers true, and stop schedu…
cemililik Aug 9, 2026
022674b
fix(roadmap): give the unowned tables an owner and the unscaffolded m…
cemililik Aug 9, 2026
3562a4f
docs(roadmap): lift Phase 02a's forward plan out of the delivery-hist…
cemililik Aug 9, 2026
80e29d6
fix(boundaries): stop the corpus sanctioning a Hub call on the host-r…
cemililik Aug 9, 2026
5d9b2b9
docs: the minor and polish sweep — waves 11 and 12
cemililik Aug 9, 2026
a9134c0
fix(links): point cross-repo URLs at HodeTech, the owner the repos ac…
cemililik Aug 9, 2026
ca1c06e
fix(audit): a tenant-override read failure falls back, it does not re…
cemililik Aug 9, 2026
78c7544
docs(roadmap): Hub P02c-1 shipped; the freeze moves to P02c-2
cemililik Aug 9, 2026
f3e2628
fix(review): close the four blockers the pre-PR review confirmed
cemililik Aug 9, 2026
44237e1
fix(review): endpoint counts, the OWNER TO contradiction, and the dem…
cemililik Aug 9, 2026
f317a97
fix(review): CI claims that are not true, and Packet 3b's own inventory
cemililik Aug 9, 2026
0036362
fix(review): restore two Accepted ADR bodies I should not have edited
cemililik Aug 9, 2026
b28763c
fix(review): the audit DDL, the partition story, and three executable…
cemililik Aug 9, 2026
37f959c
fix(review): nine verified findings from the inline review
cemililik Aug 10, 2026
a187629
fix(ci): gate deferred jobs on repository variables, not a constant f…
cemililik Aug 10, 2026
914b5f6
docs(roadmap): P02c-1 is not a frozen branch; P02c-2 is what resumes
cemililik Aug 10, 2026
bbbac2c
fix(standards): mTLS fails at the handshake, and the secret path has …
cemililik Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .claude/skills/add-architecture-test/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ The current set lives across these files; add yours to the right one:
| `AuditTests.cs` | `AuditEntry` inheritance, no direct `audit_log` writes. |
| `EntitlementTests.cs` | Plan-projected vs tenant-flag separation, FeatureKey registry. |
| `PermissionTests.cs` | Closed action set, scope correctness, denied-test presence. |
| `HubContractTests.cs` | No direct Hub-URL references, four-endpoint surface enforcement. |
| `HubContractTests.cs` | No direct Hub-URL references; Hub clients only inside the named adapters (ADR-0034). |
| `DomainGenericTests.cs` | `Core_Modules_HaveNo_DomainSpecific_Names`, no `Verticals/`. |
| `DaprDirectInjectionTests.cs` | No `IConnectionMultiplexer` / `KafkaProducer` / `VaultClient` in modules. |
| `ConventionTests.cs` | Strongly-typed ids in commands, validator pairing, etc. |
Expand Down Expand Up @@ -154,7 +154,15 @@ public void Every_TenantOwned_Table_HasRls_With_AppTenantId()
if (content.Contains("CREATE TABLE") && content.Contains("tenant_id"))
{
Assert.Contains("ENABLE ROW LEVEL SECURITY", content);
Assert.Contains("current_setting('app.tenant_id')", content);
// FORCE is the half that matters: without it the table owner bypasses
// its own policy and the whole layer is inert while ENABLE stays green.
// Matched as a regex because the canonical template writes two spaces.
Assert.Matches(@"FORCE\s+ROW LEVEL SECURITY", content);
// Must match the canonical template's exact shape. A bare
// current_setting('app.tenant_id') assertion FAILS against every
// correct migration and PASSES against the superseded one-argument
// form — see ADR-0003 Amendment 3 and 05-database.md.
Assert.Contains("NULLIF(current_setting('app.tenant_id', true), '')", content);
}
}
}
Expand Down
53 changes: 47 additions & 6 deletions .claude/skills/add-audit-coverage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,19 @@ description: >
## Purpose

Wire a new operation into LearnStack's central audit pipeline
([ADR-0016](../../../docs/decisions/0016-audit-log-subsystem.md),
([ADR-0033](../../../docs/decisions/0033-audit-durability-model.md) — the binding
durability contract; [ADR-0016](../../../docs/decisions/0016-audit-log-subsystem.md) —
superseded, read only for subsystem context;
[31-audit-subsystem.md](../../../docs/architecture/31-audit-subsystem.md),
[18-audit-coverage.md](../../../docs/standards/18-audit-coverage.md)) by extending
the module's matrix and the audit catalogue. Modules never write `audit_log`
directly; the catalogue + the `AuditLogBehavior` MediatR behaviour do.
[18-audit-coverage.md](../../../docs/standards/18-audit-coverage.md)) by extending the
module's matrix and the audit catalogue. Modules never write `audit_log` directly; the
catalogue plus the pipeline do.

The pipeline is **decide → write → reconcile**: `AuditLogBehavior` classifies at step 3
and parks an intent, `TransactionBehavior` writes the row on the business transaction
immediately before `COMMIT`, and `AuditLogBehavior` re-writes it standalone on the way out
if that transaction did not commit. You do not touch any of it — but the classification
you pick decides which of those paths a given operation takes.

## When to use

Expand Down Expand Up @@ -91,6 +99,30 @@ Use the rules from
If your operation falls outside this list, it probably belongs as a sub-resource
(see [add-permission § Closed Action Set](../add-permission/SKILL.md)).

### Step 2b: What a MUST classification now costs

Under [ADR-0033](../../../docs/decisions/0033-audit-durability-model.md) the class is
**load-bearing, not documentary**. Before you write MUST, know what you are buying:

- The row is inserted on the **same transaction** as the business write, immediately
before `COMMIT`, while `app.tenant_id` is set — so it commits with the state change or
not at all, and Row Level Security accepts it.
- If the transaction rolls back, the row is **re-written standalone** with outcome
`failed`. A MUST-class operation is never left with no row, including on the ordinary
path where a handler saves and then returns `Result.Fail(...)`.
- The operation **fails closed**. If the audit row cannot be written at all, the command
is rejected — the caller gets `503 audit_unavailable`, never a partial success.
- MUST-class events with no business transaction — `denied` outcomes, read-sensitive
queries, non-mutating security events — get a standalone row in a short transaction
that sets its own tenant GUC. Classifying a *query* MUST is legitimate and costs a
synchronous write before the result is returned.
- A tenant `AuditConfig` override can narrow SHOULD/MAY. It can never remove baseline
MUST coverage; the catalogue re-applies the MUST floor after the override.
- SHOULD/MAY stays best-effort. Choosing it is choosing a **documented accepted loss** —
write that loss into the module's matrix rather than leaving it implied.

So MUST is an availability trade as well as a compliance one. Classify deliberately.

### Step 3: Register in the catalogue

In the module's `RegisterAuditCoverage`:
Expand Down Expand Up @@ -216,8 +248,17 @@ public async Task User_NationalId_isRedacted_In_AuditSnapshot()

## Common pitfalls

- **Calling `IAuditStore` directly from the handler.** Forbidden. The
`AuditLogBehavior` does this once; a second write produces a duplicate row.
- **Calling `IAuditStore` directly from the handler.** Forbidden. The pipeline does this
once per operation; a second write produces a duplicate row. `IAuditStore` is
infrastructure, not a handler collaborator —
`Modules_Do_Not_Write_AuditLog_Directly` enforces it.
- **Adding `AuditEntry` to a module's `DbContext`** so a handler can "enrol the row in
its own `SaveChanges`". Forbidden and unnecessary: atomicity comes from the
transaction, not from sharing a `SaveChanges` call, and mapping the Audit module's
aggregate into another module's context inverts the dependency direction.
- **`UPDATE`ing an audit row to add detail after the fact.** There is no second phase.
The row is composed complete at the commit boundary, `IAuditStore` has no update
method, and `learnstack_app` holds no `UPDATE` privilege on `audit_log`.
- **Truncating snapshots silently.** If a `before/after` JSON is too large, store
an external pointer (`audit_blob_id`); never an empty object.
- **Skipping the matrix update.** `Module_<Name>_HasAuditMatrix` will fail; CI
Expand Down
137 changes: 109 additions & 28 deletions .claude/skills/add-ef-migration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,49 +87,106 @@ migrationBuilder.Sql("""
created_by uuid NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid NOT NULL,
row_version bigint NOT NULL DEFAULT 0
row_version bigint NOT NULL DEFAULT 0,
-- Exists solely so child tables can carry a composite FK into this one.
CONSTRAINT ux_<name_plural>_tenant_id_id UNIQUE (tenant_id, id)
);

CREATE INDEX ix_<name_plural>_tenant_id ON <name_plural> (tenant_id);
CREATE INDEX ix_<name_plural>_organization_id ON <name_plural> (organization_id)
WHERE organization_id IS NOT NULL;

ALTER TABLE <name_plural> ENABLE ROW LEVEL SECURITY;

CREATE POLICY <name_plural>_tenant_isolation ON <name_plural>
USING (tenant_id = current_setting('app.tenant_id')::uuid);

CREATE POLICY <name_plural>_organization_isolation ON <name_plural>
USING (
organization_id IS NULL
OR organization_id = current_setting('app.organization_id', true)::uuid
);
-- Every foreign key from this table to another tenant-owned table is
-- COMPOSITE on tenant_id:
--
-- CONSTRAINT fk_<name_plural>_<parent>
-- FOREIGN KEY (tenant_id, <parent>_id)
-- REFERENCES <parents> (tenant_id, id)
--
-- Referential-integrity checks run on behalf of the table owner and are NOT
-- subject to Row Level Security, so a single-column FK lets one tenant
-- reference another tenant's rows and no policy ever observes it.

-- One composite index, deliberately NOT partial: the policy's
-- `organization_id IS NULL` branch matches every tenant-wide row and a b-tree
-- indexes NULLs, so the non-partial form serves both branches. No standalone
-- index on tenant_id — the UNIQUE constraints above already lead with it.
-- Drop organization_id from the index when the table is not org-scoped.
CREATE INDEX ix_<name_plural>_tenant_id_organization_id
ON <name_plural> (tenant_id, organization_id);

-- ─────────────────────────────────────────────────────────────────────────
-- RLS: DO NOT WRITE THE POLICY FROM MEMORY, AND DO NOT COPY IT HERE.
--
-- Open docs/standards/05-database.md § Tenant-Owned and Organization-Scoped
-- Tables and copy the canonical block into this migration now, substituting
-- <name_plural>. That file is the only place the template exists.
--
-- The pre-2026-08-08 template lived in four documents and was wrong in all
-- four — two PERMISSIVE policies, which PostgreSQL combines with OR, so every
-- tenant-wide row was visible across tenants (ADR-0003 Amendment 3). It was
-- corrected once, in one file. A second copy is how that recurs.
--
-- Checklist for what you paste:
-- * ENABLE *and* FORCE ROW LEVEL SECURITY (both table kinds)
-- * explicit WITH CHECK (both table kinds)
-- * NULLIF(current_setting(...), '') on every GUC read (both table kinds)
--
-- TENANT-ONLY table: exactly ONE permissive policy carrying the tenant
-- predicate alone. No organization term, no restrictive guards — there is
-- no second scope to widen or narrow.
--
-- [OrganizationScoped] table: still exactly ONE permissive policy, but its
-- predicate ANDs the tenant term with the organization term; PLUS the two
-- AS RESTRICTIVE guards (FOR UPDATE, FOR DELETE), because the
-- app.scope='tenant' read hatch must not widen writes and DELETE has no
-- WITH CHECK.
-- ─────────────────────────────────────────────────────────────────────────
""");
```

**Session variable names** are canonical: `app.tenant_id` / `app.organization_id`.
Architecture test `Every_TenantOwned_Table_HasRls_With_AppTenantId` enforces.
> The canonical template is
> [05-database.md § Tenant-Owned and Organization-Scoped Tables](../../../docs/standards/05-database.md),
> and this skill deliberately does **not** mirror it — the block above tells you to open
> that file and copy from it. See
> [ADR-0003 Amendment 3](../../../docs/decisions/0003-tenant-isolation-defense-in-depth.md)
> for why the two-policy shape was withdrawn.

**Session variable names** are canonical: `app.tenant_id` / `app.organization_id` /
`app.scope`. Always pass the second `true` argument so an unset context filters the row
out instead of raising on a pooled connection.

### Step 4: Append-only / partitioned table
**Roles.** Migrations run as `learnstack_migration` (the table owner);
the application connects as `learnstack_app` (`NOBYPASSRLS`, not the owner). Grant the
new table to `learnstack_app` in the same migration, or the application cannot read it.

If the table is append-only at scale (audit, large event log):
### Step 4: Append-only table

An append-only table ships **unpartitioned**, with a composite primary key that a
future partition conversion can reuse. Do **not** write `PARTITION BY` in the first
migration: partitioning is demand-gated to
[Phase 11](../../../docs/roadmap/phase-11-production-hardening.md) on measured growth
([ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md)), and shipping
it early buys partition maintenance before there is anything to maintain.

```csharp
migrationBuilder.Sql("""
CREATE TABLE audit_log (
id uuid NOT NULL,
occurred_at timestamptz NOT NULL,
timestamp timestamptz NOT NULL DEFAULT now(),
tenant_id uuid NOT NULL,
-- ... rest ...
PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);

-- First partition (others created by retention job per ADR-0028 reservation)
CREATE TABLE audit_log_2026_06 PARTITION OF audit_log
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
-- The partition key must be IN the primary key for the Phase 11 conversion,
-- so declare the composite now even though nothing is partitioned yet.
-- Column name is `timestamp`, matching the canonical DDL in ADR-0033 and
-- 31-audit-subsystem — not `occurred_at`, which is outbox_messages' column.
CONSTRAINT audit_log_pkey PRIMARY KEY (id, timestamp)
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
""");
```

PostgreSQL has no `ALTER TABLE … PARTITION BY`, so Phase 11 does not convert this table
in place: it creates a partitioned parent, attaches this table to it, and recreates the
indexes and the policy on the parent. The composite key above is what keeps that a data
operation rather than a key migration
([ADR-0033 § Corrected `audit_log` DDL](../../../docs/decisions/0033-audit-durability-model.md)).

The Postgres trigger that rejects `UPDATE` / `DELETE` on `audit_log` lives in the
audit module's setup migration; reproduce it for any new append-only table.

Expand Down Expand Up @@ -183,8 +240,32 @@ before mutating data:
```csharp
foreach (var tenantId in tenantIds)
{
await connection.ExecuteAsync($"SET LOCAL app.tenant_id = '{tenantId}'");
await connection.ExecuteAsync("UPDATE ... WHERE tenant_id = current_setting('app.tenant_id')::uuid");
await using var tx = await connection.BeginTransactionAsync(ct);

// set_config(key, value, is_local: true) is the parameterised equivalent of
// SET LOCAL, and it MUST run inside an explicit transaction: PostgreSQL
// discards a SET LOCAL issued outside one (with a warning), so the UPDATE
// below would otherwise run with no tenant context at all — which, under a
// NULLIF-wrapped policy, means it silently updates zero rows.
//
// Parameterised, not interpolated. String-interpolated SQL is banned by
// 05-database.md § Forbidden.
await connection.ExecuteAsync(
"SELECT set_config('app.tenant_id', @tenantId, true)",
new { tenantId = tenantId.ToString() },
transaction: tx);

// No `WHERE tenant_id = current_setting(...)` clause. The connection runs as
// learnstack_migration, which is NOBYPASSRLS against a table that is FORCE ROW
// LEVEL SECURITY, so the policy scopes the statement to this tenant on its own;
// restating the predicate in application SQL is a second copy of the policy that
// can drift from the first. Note the consequence: skip the set_config above and
// the UPDATE matches ZERO rows and still reports success.
await connection.ExecuteAsync(
"UPDATE enrollments SET source = 'legacy' WHERE source IS NULL",
transaction: tx);

await tx.CommitAsync(ct);
}
```

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/add-feature-key/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ See [add-feature-gated-ui](../add-feature-gated-ui/SKILL.md) for hook usage.

### Step 4: Hub-side plan editor (if PlanProjected)

For plan-projected keys, the Hub operator portal (`learnstack-hub-web` in the
For plan-projected keys, the Hub operator portal (`operator-portal` in the
separate repo) lists every key declared in the `FeatureKeys` catalogue. The Hub
plan editor surfaces them as toggle checkboxes. The Hub publishes the resulting
JSON entitlement projection to LearnStack via
Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/add-frontend-route/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ description: >
portal), and SDK-based data fetching. USE FOR: a new public page, Studio screen,
or learner / instructor portal screen. DO NOT USE FOR: thin BFF proxy endpoints
(those live in `app/api/`), routes for the operator portal (that's the separate
`learnstack-hub-web` app), or hand-rolled `fetch` to the backend (use the typed
`operator-portal` app), or hand-rolled `fetch` to the backend (use the typed
SDK).
---

Expand All @@ -30,7 +30,7 @@ contract per

## When not to use

- Operator portal pages — they live in `learnstack-hub-web`, a separate repo.
- Operator portal pages — they live in `operator-portal`, a separate repo.
- Calling the API directly from a Client Component without the SDK — forbidden by
ESLint (`no-restricted-imports`).
- Routes that bypass tenant resolution — every authenticated route requires a
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/add-mediatr-handler/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ description: >
## Purpose

Write a command/query handler that participates correctly in the LearnStack MediatR
pipeline: `Validation → Logging → Audit → TenantContext → Authorization →
pipeline: `Validation → Logging → AuditLog → TenantContext → Authorization →
Transaction → OutboxFlush → Handler`. The pipeline is shared (per
[ADR-0032 § Sub-decision 2](../../../docs/decisions/0032-exception-handling-logging-and-observability.md)
and [Standards 02 § Pipeline Behaviors](../../../docs/standards/02-backend-coding.md)),
Expand Down
6 changes: 3 additions & 3 deletions .claude/skills/add-permission/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ education.course.write
education.course.delete
education.course_publication.write ← "publish a course"
enrollment.enrollment.write
enrollment.entitlement.read
enrollment.course_access.read
tenancy.organization.admin
identity.impersonation.write
audit.export.read
Expand All @@ -83,12 +83,12 @@ In `<Module>.Application/<Module>Module.cs`:
public void RegisterPermissions(IPermissionRegistry registry)
{
registry.Tenant(
key: "enrollment.entitlement.read",
key: "enrollment.course_access.read",
description: "View entitlements",
defaultGrants: [Roles.TenantAdmin, Roles.OrgAdmin, Roles.Instructor]);

registry.Tenant(
key: "enrollment.entitlement.write",
key: "enrollment.course_access.write",
description: "Grant or revoke entitlements",
defaultGrants: [Roles.TenantAdmin]);

Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/add-provider-adapter/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ the canonical wiring per
building blocks, handled by
[wire-cross-cutting-foundation](../wire-cross-cutting-foundation/SKILL.md).
Dapr's runtime provides retry + DLQ + circuit-breaker semantics already.
- The four Hub HTTPS endpoints (`IEntitlementProvider`, `IUsageReporter`,
- The Hub HTTPS contract surface (`IEntitlementProvider`, `IUsageReporter`,
`IHubTenantSync`) — those use the dedicated mTLS + signed JWT + HMAC
wrapper per [ADR-0019](../../../docs/decisions/0019-learnstack-hub.md).
- Pure in-process integrations (a JSON converter, a hash function) — no
Expand Down Expand Up @@ -313,7 +313,7 @@ In `docs/modules/<module>/providers.md`, add the adapter:
- Adapter: `LearnStack.Infrastructure.LiveClassroom.LiveKit.LiveKitClient`
- Resilience section: `Resilience:liveclass:`
- Exception subclass: `LiveClassProviderException`
- ADR: [ADR-0005](../../decisions/0005-live-classroom-media-stack.md)
- ADR: [ADR-0005](../../../docs/decisions/0005-live-classroom-media-stack.md)
```

## Validation
Expand Down
Loading
Loading