|
1 | 1 | # Phase 02a: Platform Kernel, Multi-Tenancy, Organization, and Foundation Sockets |
2 | 2 |
|
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 |
5 | 5 | > is independently reviewable in its own commit, matching the |
6 | 6 | > [Phase 01 cadence](phase-01-repository-tooling.md). The order is dependency-driven: a |
7 | 7 | > later packet may consume any earlier packet's deliverables, never the reverse. |
|
16 | 16 | > | 4 | API conventions | ✅ [record](#delivery-record-packet-4) | |
17 | 17 | > | 5 | Foundation ports and default implementations | ✅ [record](#delivery-record-packet-5) | |
18 | 18 | > | 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) | |
20 | 20 | > | 8 | Tenant Customization foundation | ⏳ [scope](#packet-sequence) | |
21 | 21 | > | 9 | Audit infrastructure and the entitlement socket | ⏳ [scope](#packet-sequence) | |
22 | 22 | > | 10 | Architecture tests green and phase exit | ⏳ [scope](#packet-sequence) | |
|
30 | 30 | > [`## Delivery Record (Packet 3b)`](#delivery-record-packet-3b), and Packet 4 in |
31 | 31 | > [`## Delivery Record (Packet 4)`](#delivery-record-packet-4), Packet 5 in |
32 | 32 | > [`## 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 |
34 | 35 | > because the frozen one is scoped to packets 0–3.** |
35 | 36 |
|
36 | 37 | ## Goal |
@@ -1400,8 +1401,6 @@ The remaining exit gates (tenant + organization resolution, isolation tests runn |
1400 | 1401 | conventions, two seed tenants, architecture-test catalogue green) close as Packets |
1401 | 1402 | 3b–10 ship. |
1402 | 1403 |
|
1403 | | - |
1404 | | - |
1405 | 1404 | ## Delivery Record (Packets 0–3) |
1406 | 1405 |
|
1407 | 1406 | 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. |
2423 | 2422 | > does not mistake a green suite for proof of them: a cross-module read inside the |
2424 | 2423 | > ambient transaction returning rows, and an outer failure after an inner write |
2425 | 2424 | > 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. |
0 commit comments