Skip to content

Commit 8428639

Browse files
cemililikclaude
andcommitted
docs(roadmap): close Packet 7 with the record of what it got wrong
The delivery record, the status blocks in three carriers, the three architecture rules this packet added and never registered, and the two isolation entries that promised a request-level case without naming one. Measured at close: 1187 tests green — counted from a run, not computed. An earlier commit message in this packet carried a total that was eight short because it summed two review rounds instead of applying the second on top. The record is long for the reason Packets 5 and 6's were. Eleven steps, each reviewed twice, and the second round repeatedly found the first round's fix: a conflict translation that turned a 409 into a 500, a seeder idempotency check that masked validation failures, a cache invalidation whose comment claimed a guarantee its placement did not give. The sharpest finding was in the last step — a test that asserted an anonymous POST failed, and therefore passed against a deleted endpoint and against a database with every policy dropped. It also records what the packet did not ship and who owns each: nothing is audited until Packet 9, nothing is authorized until Phase 03, PlatformAdminScope ships with no reachable caller, and the host-resolution cache is invalidated before the commit rather than after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 94c8cab commit 8428639

4 files changed

Lines changed: 198 additions & 29 deletions

File tree

‎CLAUDE.md‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,10 @@ repository holds only LearnStack's side of the boundary, in
3636

3737
**Phase 01 complete.
3838
[Phase 02a](docs/roadmap/phase-02a-kernel-tenancy.md) in progress —
39-
packets 0–3, 3b, 4, 5 and 6 shipped; packets 3b–10 were re-scoped on 2026-08-08
39+
packets 0–3, 3b, 4, 5, 6 and 7 shipped; packets 3b–10 were re-scoped on 2026-08-08
4040
after a four-report audit of the corpus.
41-
[Packet 7](docs/roadmap/phase-02a-kernel-tenancy.md#packet-sequence) — host and
42-
tenant resolution, the EF query filters, the request-level isolation suite and
43-
the two seed tenants — is next.**
41+
[Packet 8](docs/roadmap/phase-02a-kernel-tenancy.md#packet-sequence) — the Tenant
42+
Customization foundation — is next.**
4443

4544
**Phase 01** shipped the .NET 10 solution scaffold under `backend/`
4645
(core + 7 modules × 4 projects + 4 test projects including the
@@ -109,6 +108,24 @@ is long because several of its defects were introduced by the *fix* for an
109108
earlier one, and because three tests were caught agreeing with the code instead
110109
of constraining it — the packet's most repeated lesson.
111110

111+
**Packet 7** shipped what a request does with a tenant: host classification and
112+
resolution, `EffectiveHost` normalization, the negative host cache, the
113+
four-origin `TenantContextFactory`, and `TenantContextBehavior`'s two-gate
114+
authority ceiling with `[AllowsUnresolvedTenantContext]` and `[PublicSurface]` as
115+
enumerated holes. With them: `ProvisionTenantCommand`, the one operation
116+
[ADR-0042](docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md)
117+
sanctions to write two aggregate roots on one transaction; `CreateOrganizationCommand`
118+
and `MapHostToTenantCommand`, both taking their tenant from the context and never
119+
from the request; `LearnStack.Tools.Seeder` and the two seed tenants
120+
(`demo-english`, `demo-yoga`) written through those commands, so `make seed` exercises
121+
the request path rather than a second one; and the request-level isolation suite —
122+
the first fixture pairing the real middleware chain with a real database. Its record,
123+
[Delivery Record (Packet 7)](docs/roadmap/phase-02a-kernel-tenancy.md#delivery-record-packet-7),
124+
is long for the reason Packets 5 and 6's were: eleven steps, each reviewed twice,
125+
and the second round repeatedly found the first round's fix. The sharpest finding
126+
was a test that asserted an anonymous POST failed and therefore passed against a
127+
deleted endpoint.
128+
112129
**Packet 6** shipped the tenancy schema: the four database roles, ten tables in
113130
two independent migration chains, and every one of them under `ENABLE` **and**
114131
`FORCE ROW LEVEL SECURITY` with the corrected

‎README.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,12 @@ for LearnStack's side of the boundary.
4141
## Status
4242

4343
**Phase 01 complete. [Phase 02a](docs/roadmap/phase-02a-kernel-tenancy.md) in progress —
44-
packets 0–3, 3b, 4, 5 and 6 shipped; packets 4–10 re-scoped on 2026-08-08.
45-
[Packet 7](docs/roadmap/phase-02a-kernel-tenancy.md#packet-sequence) — host and tenant
46-
resolution, the query filters, and the two seed tenants — is next.**
44+
packets 0–3, 3b, 4, 5, 6 and 7 shipped; packets 4–10 re-scoped on 2026-08-08.
45+
Packet 7 landed host and tenant resolution, the query filters, tenant provisioning
46+
and the two seed tenants — `demo-english` and `demo-yoga`, which `make seed` writes
47+
through the same commands a request uses.
48+
[Packet 8](docs/roadmap/phase-02a-kernel-tenancy.md#packet-sequence) — the Tenant
49+
Customization foundation — is next.**
4750

4851
Phase 01 shipped the .NET 10 solution scaffold, the `pnpm` frontend monorepo
4952
(`apps/web` + `packages/{config,ui,sdk}`), the local-dev `docker-compose` stack, and the

‎docs/roadmap/phase-02a-kernel-tenancy.md‎

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Phase 02a: Platform Kernel, Multi-Tenancy, Organization, and Foundation Sockets
22

3-
> **Status (2026-08-28).** Phase 02a in progress. Packets 0–3, 3b, 4, 5 and 6 shipped; the
4-
> 2026-08-08 restructure re-scoped packets 4–10 and added packet 3b. Each packet
3+
> **Status (2026-09-03).** Phase 02a in progress. Packets 0–3, 3b, 4, 5, 6 and 7 shipped;
4+
> the 2026-08-08 restructure re-scoped packets 4–10 and added packet 3b. Each packet
55
> is independently reviewable in its own commit, matching the
66
> [Phase 01 cadence](phase-01-repository-tooling.md). The order is dependency-driven: a
77
> later packet may consume any earlier packet's deliverables, never the reverse.
@@ -16,7 +16,7 @@
1616
> | 4 | API conventions | ✅ [record](#delivery-record-packet-4) |
1717
> | 5 | Foundation ports and default implementations | ✅ [record](#delivery-record-packet-5) |
1818
> | 6 | Tenancy schema and the corrected RLS template | ✅ [record](#delivery-record-packet-6) |
19-
> | 7 | Tenant and organization resolution, isolation, two tenants | ⏳ [scope](#packet-sequence) |
19+
> | 7 | Tenant and organization resolution, isolation, two tenants | ✅ [record](#delivery-record-packet-7) |
2020
> | 8 | Tenant Customization foundation | ⏳ [scope](#packet-sequence) |
2121
> | 9 | Audit infrastructure and the entitlement socket | ⏳ [scope](#packet-sequence) |
2222
> | 10 | Architecture tests green and phase exit | ⏳ [scope](#packet-sequence) |
@@ -30,7 +30,8 @@
3030
> [`## Delivery Record (Packet 3b)`](#delivery-record-packet-3b), and Packet 4 in
3131
> [`## Delivery Record (Packet 4)`](#delivery-record-packet-4), Packet 5 in
3232
> [`## Delivery Record (Packet 5)`](#delivery-record-packet-5), and Packet 6 in
33-
> [`## Delivery Record (Packet 6)`](#delivery-record-packet-6) — each kept separate
33+
> [`## Delivery Record (Packet 6)`](#delivery-record-packet-6) and Packet 7 in
34+
> [`## Delivery Record (Packet 7)`](#delivery-record-packet-7) — each kept separate
3435
> because the frozen one is scoped to packets 0–3.**
3536
3637
## Goal
@@ -1400,8 +1401,6 @@ The remaining exit gates (tenant + organization resolution, isolation tests runn
14001401
conventions, two seed tenants, architecture-test catalogue green) close as Packets
14011402
3b–10 ship.
14021403

1403-
1404-
14051404
## Delivery Record (Packets 0–3)
14061405

14071406
Shipped history, kept verbatim. Packets 0–3 and the 2026-08-08 restructure annotation
@@ -2423,3 +2422,94 @@ in a record rather than in production.
24232422
> does not mistake a green suite for proof of them: a cross-module read inside the
24242423
> ambient transaction returning rows, and an outer failure after an inner write
24252424
> leaving zero rows in both modules. Both need a second module `DbContext`.
2425+
2426+
## Delivery Record (Packet 7)
2427+
2428+
Kept separate from the records above, and long for the reason Packets 5 and 6 were:
2429+
most of what follows is a defect this packet introduced and its own review rounds
2430+
found. Eleven steps, each reviewed twice — once by an Opus agent, once by a Sonnet
2431+
one — and the second round repeatedly found the first round's fix.
2432+
2433+
> **Packet 7 — Tenant and organization resolution, isolation, two tenants ✅**
2434+
>
2435+
> **Measured at close: 1187 tests green** — 1 contract, 76 architecture, 802 unit,
2436+
> 308 integration. Counted from a run, not computed from a plan; an earlier commit
2437+
> message in this packet carried an arithmetic total that was eight short.
2438+
2439+
### What shipped
2440+
2441+
- **Host and tenant resolution.** `HostClassificationMiddleware`,
2442+
`TenantResolverMiddleware`, `CachedHostToTenantResolver`, `EffectiveHost`
2443+
normalization, the `UnknownHostCache`, and the four-origin `TenantContextFactory`
2444+
behind a pure, total, synchronous `Create`.
2445+
- **The authority ceiling.** `TenantContextBehavior`'s two nested gates, with
2446+
`[AllowsUnresolvedTenantContext]` and `[PublicSurface]` as enumerated holes and an
2447+
architecture rule counting each.
2448+
- **Provisioning.** `ProvisionTenantCommand` — the one operation
2449+
[ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md)
2450+
sanctions to write two aggregate roots on one transaction — with
2451+
`IProvisionsTenant`, the `SetProvisioningTenantContextAsync` announcement seam, and
2452+
`IAggregateWriteStore<TRoot, TId>`, the codebase's first persistence port.
2453+
- **`CreateOrganizationCommand` and `MapHostToTenantCommand`,** both taking their
2454+
tenant from the context and never from the request, plus
2455+
`PlatformHostMapping.Create`, which did not exist and without which nothing could
2456+
write a host row at all.
2457+
- **Two seed tenants** — `demo-english` and `demo-yoga`, two organizations each, one
2458+
host row each of a different live class — written by `LearnStack.Tools.Seeder`
2459+
through the same commands a request sends, because ADR-0042 requires the seeder not
2460+
to hold a second copy of the sanctioned cross-aggregate write. `make seed` runs it.
2461+
- **The request-level isolation suite,** the first fixture pairing
2462+
`WebApplicationFactory<Program>` with a real Postgres container: five cases driven
2463+
by the host header alone, with no stubbed `ITenantContext`.
2464+
2465+
### What this packet got wrong, and how it was found
2466+
2467+
- **A `[Theory]` that agreed with the code.** Three separate tests were caught
2468+
asserting what the implementation did rather than what it owed. The sharpest was in
2469+
the final step: `Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck` performed no
2470+
`INSERT` at all — it asserted that an anonymous POST failed, which a 404 satisfies,
2471+
so it passed against a **deleted endpoint** and against a database with every policy
2472+
dropped. `Org_X_cannot_read_Org_Y` read a tenant-wide table and narrowed the rows
2473+
with a `Where` the test's own probe handler wrote.
2474+
- **A validator whose every message was discarded.** `ValidationBehavior` builds its
2475+
response from `ErrorCode ?? ErrorMessage`, and FluentValidation always fills
2476+
`ErrorCode` with the validator's own name — so a malformed slug and a tenant sharing
2477+
its organization's id both reached the caller as `lockey_predicatevalidator`, the
2478+
second under an empty field key.
2479+
- **A fix that made things worse.** Translating a uniqueness conflict into four
2480+
module-specific error codes turned a duplicate slug from a 409 into a **500**:
2481+
`HttpStatusMap` is a closed table of cross-cutting codes and falls through to
2482+
`InternalServerError`. The shape that works is the one `ValidationBehavior` already
2483+
used — a canonical top-level code plus per-field details.
2484+
- **An architecture rule with three escapes.** The cross-aggregate rule missed a fused
2485+
port, every `INotificationHandler`, and any non-public constructor — each proven by
2486+
dropping the shape into a production assembly and watching 76 cases pass. Worse, the
2487+
ADR amendment written alongside it *claimed* the fused case was caught.
2488+
- **`make seed` could not run.** It read `ConnectionStrings__Default` from the process
2489+
environment, which nothing populates: the Makefile has no `include .env`. The error
2490+
it printed offered a remedy — copy `.env.example` to `.env` — that is exactly the
2491+
state that already fails.
2492+
- **An idempotency check that masked failures.** The seeder treated any
2493+
`business_rule_violation` as "already seeded", which was safe while provisioning was
2494+
the only command and stopped being safe the moment `MapHostToTenantCommand` returned
2495+
the same code for three different conditions.
2496+
- **Two obligations inherited without noticing.** ADR-0036 assigns the
2497+
`Tenancy:PlatformHosts` collision check to "whichever packet builds the host-mapping
2498+
writer", and `UnknownHostCache.Forget` had no caller because none existed. This
2499+
packet built that writer.
2500+
- **A promise from ADR-0037 that Packet 6 did not keep.** `IIdempotencyStore` still
2501+
took a raw `Guid` tenant after the ADR said it and `ITenantContext.TenantId` "both
2502+
move together". Amendment 4 records the conversion and the divergence.
2503+
2504+
### What it did not ship, and who owns each
2505+
2506+
- **Nothing is audited.** `AuditLogBehavior` lights up in Packet 9; `TransactionBehavior`
2507+
carries the `TODO` marking the line the MUST-class write goes on.
2508+
- **Nothing is authorized.** No permission key is registered; the three commands are
2509+
reachable only from the seeder and, from Phase 02c, the Hub over `/api/internal/*`.
2510+
Phase 03 ships the catalogue.
2511+
- **`PlatformAdminScope` has no reachable caller.** It ships with its gate closed;
2512+
Packet 9's GDPR handler is the first.
2513+
- **The host-resolution cache is invalidated before the commit, not after.** The
2514+
guarantee is therefore the request *after* the write, not the one racing it; closing
2515+
the rest needs a post-commit seam on `IUnitOfWork`, whose surface ADR-0040 governs.

‎docs/standards/21-architecture-tests-catalogue.md‎

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -990,13 +990,19 @@ first two rows are coverage checks; the last three are the proof.
990990
- **Source:** ADR-0003 Amendment 3 § Test requirement.
991991
- **Type:** **integration** test (Testcontainers + PostgreSQL), not an architecture
992992
test. **Kind:** runtime.
993-
- **Status:** **Implemented** (Packet 6 step 4,
994-
`LearnStack.Tests.Integration`, `TenancySchemaTests`). It moved forward because
995-
the schema's own assertions needed the two-tenant seed anyway: without rows for
996-
both tenants, every count assertion in that class passed against dropped
997-
policies. Packet 7 re-runs it through `TenantResolverMiddleware` and the EF
998-
query filters rather than through `set_config`.
999-
- **Phase:** 02a (Packet 6 ships the schema-level case; Packet 7 the request-level one).
993+
- **Status:** **Implemented, twice.** The schema-level case is Packet 6 step 4
994+
(`TenancySchemaTests`); the request-level one is Packet 7 step 11
995+
(`Database/TenantIsolationHttpTests`), which drives it through
996+
`HostClassificationMiddleware`, `TenantResolverMiddleware`, the announcement and the
997+
EF query filters, with the host header as the only input and no stubbed
998+
`ITenantContext`. The schema-level case moved forward because that class's own
999+
assertions needed the two-tenant seed anyway: without rows for both tenants, every
1000+
count in it passed against dropped policies.
1001+
- **Reads `tenant_settings`, not `platform_host_to_tenant`.** The row shape the rule
1002+
names is tenant-owned with `organization_id IS NULL`; the host table is
1003+
platform-scoped and its policy has no organization term, so reading it would name the
1004+
wrong mechanism. The first version of the request-level case did exactly that.
1005+
- **Phase:** 02a (Packet 6 the schema-level case; Packet 7 the request-level one).
10001006

10011007
#### `Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck`
10021008

@@ -1008,18 +1014,71 @@ first two rows are coverage checks; the last three are the proof.
10081014
- **Source:** ADR-0003 Amendment 3 § Test requirement;
10091015
[05-database.md](05-database.md).
10101016
- **Type:** **integration** test (Testcontainers + PostgreSQL). **Kind:** runtime.
1011-
- **Status:** **Implemented** (Packet 6 step 4,
1012-
`LearnStack.Tests.Integration`, `TenancySchemaTests`) — as a `[Theory]` over
1013-
both halves, because `WITH CHECK` guards `INSERT` and `UPDATE` and a rule
1014-
covering one leaves the other open.
1015-
- **Phase:** 02a (Packet 6 ships the schema-level case; Packet 7 the request-level one).
1017+
- **Status:** **Implemented, twice.** Packet 6 step 4 (`TenancySchemaTests`) as a
1018+
`[Theory]` over both halves, because `WITH CHECK` guards `INSERT` and `UPDATE` and a
1019+
rule covering one leaves the other open. Packet 7 step 11
1020+
(`Database/TenantIsolationHttpTests`) issues the write through a request: raw SQL on
1021+
the ambient connection, so no query filter is in front of it and only `WITH CHECK` can
1022+
refuse. The first version of that case asserted merely that an anonymous POST failed,
1023+
and passed against a **deleted endpoint** and against a database with every policy
1024+
dropped — which is why the entry now says what the case must observe rather than what
1025+
it must return.
1026+
- **Phase:** 02a (Packet 6 the schema-level case; Packet 7 the request-level one).
10161027

10171028
`Tenant_A_cannot_read_Tenant_B_data`, `Org_X_cannot_read_Org_Y_within_TenantA` and
10181029
`Unsetting_tenant_context_returns_zero_rows_through_RLS` are ordinary integration tests
1019-
named in the phase document rather than catalogue-governed rules; all three shipped
1020-
alongside the two rules above in Packet 6 step 4 and are listed in
1021-
[Phase 02a Packet 7](../roadmap/phase-02a-kernel-tenancy.md), which re-runs them through
1022-
the request path.
1030+
named in the phase document rather than catalogue-governed rules. All three shipped
1031+
alongside the two rules above in Packet 6 step 4, and Packet 7 step 11 re-runs them
1032+
through the request path in `Database/TenantIsolationHttpTests`.
1033+
1034+
Three things are worth recording about that second run, because each was a defect in its
1035+
first version. `Org_X_…` must read an **organization-scoped** table — `tenant_settings`,
1036+
whose policy carries an organization term — not `organizations`, which is tenant-wide and
1037+
where every organization is visible to every other by design; reading the latter and
1038+
narrowing the rows in the test's own handler tested the test.
1039+
`Unsetting_tenant_context_…` must actually run a query under an unresolved context, which
1040+
takes a `PlatformHost` request (a host in `Tenancy:PlatformHosts`); asserting a 404 for an
1041+
unknown host instead exercises the resolver and never reaches a table. And the suite as a
1042+
whole constrains the **composite** answer, not one layer: measured, deleting both EF query
1043+
filters leaves all five green because RLS holds, disabling RLS leaves the four reads green
1044+
because the filters hold, and removing both turns all five red.
1045+
1046+
#### `Every_Scoping_Interface_Carries_Its_Marker`
1047+
1048+
- **Asserts:** every entity implementing a scoping interface — `ITenantOwned`,
1049+
`IOrganizationScoped` — also carries the marker attribute the filter and policy
1050+
generators read. An entity that implements one and not the other is scoped in the type
1051+
system and unscoped everywhere it matters.
1052+
- **Source:** [ADR-0003 Amendment 3](../decisions/0003-tenant-isolation-defense-in-depth.md).
1053+
- **Type:** xUnit + reflection. **Kind:** structural.
1054+
- **Status:** **Implemented** (Packet 7, `LearnStack.Tests.Architecture`).
1055+
- **Phase:** 02a Packet 7.
1056+
1057+
#### `The_Request_Filter_Sees_Every_Shape_MediatR_Dispatches`
1058+
1059+
- **Asserts:** the predicate that enumerates request types covers every shape MediatR
1060+
dispatches, so a rule written over "all requests" is not silently blind to one of them.
1061+
Measured facts behind it: `IStreamRequest<T>.GetInterfaces()` is empty and
1062+
`IBaseRequest.IsAssignableFrom(IStreamRequest<>)` is false, so a filter written the
1063+
obvious way misses streamed requests entirely.
1064+
- **Source:** [04-api-design.md](04-api-design.md).
1065+
- **Type:** xUnit + reflection. **Kind:** structural.
1066+
- **Status:** **Implemented** (Packet 7, `LearnStack.Tests.Architecture`).
1067+
- **Phase:** 02a Packet 7.
1068+
1069+
#### `The_Sweep_Covers_Every_Production_Assembly`
1070+
1071+
- **Asserts:** every `LearnStack.*` project under `backend/src` is loadable by the rules
1072+
that sweep production assemblies. A project the sweep cannot load is a project every
1073+
reflection rule silently skips, which is worse than a rule that fails: it reports green
1074+
over code it never read.
1075+
- **Note:** it is why adding a project — `LearnStack.Tools.Seeder` in Packet 7 step 10 —
1076+
requires a `ProjectReference` from the architecture test project. The rule names the
1077+
remedy in its own failure message.
1078+
- **Source:** [21-architecture-tests-catalogue.md § What a structural test proves](#what-a-structural-test-proves--and-what-it-does-not).
1079+
- **Type:** xUnit + reflection. **Kind:** structural.
1080+
- **Status:** **Implemented** (Packet 7, `LearnStack.Tests.Architecture`).
1081+
- **Phase:** 02a Packet 7.
10231082

10241083
#### `Tenant_Context_Guard_Fires_Only_On_An_Unmarked_Transaction`
10251084

0 commit comments

Comments
 (0)