From 80f634a0ddb6fa6be98e1650e6eb4f9ab1be6a7e Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 27 Aug 2026 15:35:32 +0300 Subject: [PATCH 01/35] docs(roadmap): close Packet 5 and point at Packet 6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #13 merged, so the packet is done and the corpus says so in the four places that carry state. - The Phase 02a Status block dates to 2026-08-27 and marks Packet 5 ✅ with its record link; the packet-sequence entry already did. - The Packet 5 record gains the five review rounds that ran after it was drafted. The packet closed at the merge, not at the draft, so these belong in it rather than after it: the factory budget that was not a budget in three successive shapes, a consumer span that reported success for a failed delivery, three test-side defects of the kind the record already names as the packet's main lesson, and a set of documentation contradictions each contained inside one file. - README.md, docs/roadmap/README.md and CLAUDE.md still said packets 0–3 and 3b. They now say 0–3, 3b, 4 and 5, summarise what 4 and 5 shipped, and name Packet 6 — the tenancy schema and the first migration written against the corrected RLS template — as next. - The record said JsonSerializer.Serialize through the interface emits four members. IIntegrationEvent declares five. Same off-by-one this packet's last round corrected in 15-event-and-outbox.md. The frozen Packets 0–3 record still schedules DaprSecretProvider to Packet 5, which the 2026-08-08 restructure moved to Phase 11. That record is history and is not rewritten; the corrected placement is in Packet 5's own record. No source comment carries the stale pointer. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 23 ++++++- README.md | 17 +++++- docs/roadmap/README.md | 2 +- docs/roadmap/phase-02a-kernel-tenancy.md | 76 +++++++++++++++++++++++- 4 files changed, 112 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8ef9c953..d2326a15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,10 @@ repository holds only LearnStack's side of the boundary, in **Phase 01 complete. [Phase 02a](docs/roadmap/phase-02a-kernel-tenancy.md) in progress — packets 0–3, 3b, 4 and 5 shipped; packets 4–10 were re-scoped on 2026-08-08 -after a four-report audit of the corpus.** +after a four-report audit of the corpus. +[Packet 6](docs/roadmap/phase-02a-kernel-tenancy.md#packet-sequence) — the +tenancy schema and the first migration written against the corrected RLS +template — is next.** **Phase 01** shipped the .NET 10 solution scaffold under `backend/` (core + 7 modules × 4 projects + 4 test projects including the @@ -88,6 +91,24 @@ client had no way to question — and the two that did not were worse: one hande back a truncated body under a `200`, the other a `500` per request that an anonymous caller could trigger at will. +**Packet 5** shipped the foundation ports with the implementations that +actually run today — `ICacheService` / `InMemoryCacheService`, `IEventBus` / +`InProcessEventBus`, and Packet 3's `ISecretProvider` / +`ConfigurationSecretProvider` — each selected at a single composition-root site +so Phase 11's adapter is one line rather than a search. With them: `CacheKey` +and `EnsureValid`, because there is no query filter and no RLS policy in front +of a dictionary, so the key *is* the isolation boundary; the +[ADR-0038](docs/decisions/0038-cross-cutting-port-and-event-contracts.md) port +and event contracts, including `IntegrationEventEnvelope` and the non-generic +`PublishAsync`; and the compose `gated` profile that took Kafka, Valkey, Vault, +APISIX and the two Dapr containers out of `make dev`. `IHostToTenantResolver` +and `IEntitlementProvider` are **not** here — they need the tenancy schema and +belong to Packets 7 and 9. Its record, +[Delivery Record (Packet 5)](docs/roadmap/phase-02a-kernel-tenancy.md#delivery-record-packet-5), +is long because several of its defects were introduced by the *fix* for an +earlier one, and because three tests were caught agreeing with the code instead +of constraining it — the packet's most repeated lesson. + **The 2026-08-08 restructure** re-scoped packets 3b–10 along three lines, all recorded in the Phase 02a Status block: diff --git a/README.md b/README.md index 3d6bce3d..9287f6bd 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,9 @@ for LearnStack's side of the boundary. ## Status **Phase 01 complete. [Phase 02a](docs/roadmap/phase-02a-kernel-tenancy.md) in progress — -packets 0–3 and 3b shipped; packets 4–10 re-scoped on 2026-08-08.** +packets 0–3, 3b, 4 and 5 shipped; packets 4–10 re-scoped on 2026-08-08. +[Packet 6](docs/roadmap/phase-02a-kernel-tenancy.md#packet-sequence) — the tenancy +schema and the corrected RLS template — is next.** Phase 01 shipped the .NET 10 solution scaffold, the `pnpm` frontend monorepo (`apps/web` + `packages/{config,ui,sdk}`), the local-dev `docker-compose` stack, and the @@ -58,6 +60,19 @@ management — whose *timing* later moved to Phase 11), the shared kernel core, cross-cutting foundation. [Packet 3b](docs/roadmap/phase-02a-kernel-tenancy.md#delivery-record-packet-3b) then repaired what a corpus audit found — before any consumer existed. +[Packet 4](docs/roadmap/phase-02a-kernel-tenancy.md#delivery-record-packet-4) shipped the +API conventions: `/api/v{N}` routing, one RFC 7807 shape on every error, cursor +pagination and the sort grammar, the +[ADR-0036](docs/decisions/0036-tenant-resolution-trusted-inputs.md) tenancy edge, +idempotency keys and ETag concurrency, and the first working SDK generation. +[Packet 5](docs/roadmap/phase-02a-kernel-tenancy.md#delivery-record-packet-5) shipped the +foundation ports with the implementations that actually run today — `ICacheService` / +`InMemoryCacheService`, `IEventBus` / `InProcessEventBus`, `ISecretProvider` / +`ConfigurationSecretProvider` — each selected at one composition-root site, and moved +the seven demand-gated services out of the daily `make dev` loop. Both records list the +defects the packet introduced and caught in its own review rounds alongside what it +built. + The 2026-08-08 restructure moved correctness earlier (the corrected RLS template in [ADR-0003 Amendment 3](docs/decisions/0003-tenant-isolation-defense-in-depth.md), durable MUST-class audit in [ADR-0033](docs/decisions/0033-audit-durability-model.md)), moved diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 33c579e0..8f4495f7 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -42,7 +42,7 @@ not deferred to the showcase phase. - [Phase 00: Product Strategy and Architecture Definition](phase-00-product-architecture.md) — **complete** - [Phase 01: Repository, Tooling, and Local Infrastructure](phase-01-repository-tooling.md) — **complete** -- [Phase 02a: Platform Kernel, Multi-Tenancy, Organization, and Foundation Sockets](phase-02a-kernel-tenancy.md) — **in progress** (packets 0–3 and 3b shipped) +- [Phase 02a: Platform Kernel, Multi-Tenancy, Organization, and Foundation Sockets](phase-02a-kernel-tenancy.md) — **in progress** (packets 0–3, 3b, 4 and 5 shipped; packet 6 next) - [Phase 02d: Two-Tenant Walking Skeleton](phase-02d-walking-skeleton.md) - [Phase 02b: Identity Integration, Session, and Events](phase-02b-events-auth.md) - [Phase 03: Identity Domain, Authorization, and Admin Foundation](phase-03-identity-admin.md) diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index e9644b9e..a10f25b4 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -1,6 +1,6 @@ # Phase 02a: Platform Kernel, Multi-Tenancy, Organization, and Foundation Sockets -> **Status (2026-08-20).** Phase 02a in progress. Packets 0–3, 3b and 4 shipped; the +> **Status (2026-08-27).** Phase 02a in progress. Packets 0–3, 3b, 4 and 5 shipped; the > 2026-08-08 restructure re-scoped packets 4–10 and added packet 3b. Each packet > is independently reviewable in its own commit, matching the > [Phase 01 cadence](phase-01-repository-tooling.md). The order is dependency-driven: a @@ -14,7 +14,7 @@ > | 3 | Cross-cutting foundation | ✅ [record](#delivery-record-packets-03) | > | 3b | Decision repair | ✅ [record](#delivery-record-packet-3b) | > | 4 | API conventions | ✅ [record](#delivery-record-packet-4) | -> | 5 | Foundation ports and default implementations | ⏳ [scope](#packet-sequence) | +> | 5 | Foundation ports and default implementations | ✅ [record](#delivery-record-packet-5) | > | 6 | Tenancy schema and the corrected RLS template | ⏳ [scope](#packet-sequence) | > | 7 | Tenant and organization resolution, isolation, two tenants | ⏳ [scope](#packet-sequence) | > | 8 | Tenant Customization foundation | ⏳ [scope](#packet-sequence) | @@ -1941,7 +1941,7 @@ one. > > **A trap the non-generic port creates, closed with it.** With `IIntegrationEvent` > as the declared type at every dispatch boundary, -> `JsonSerializer.Serialize(@event)` emits four members and silently drops +> `JsonSerializer.Serialize(@event)` emits five members and silently drops > everything the concrete event added — valid JSON, no exception, committed inside > the transaction that reported success, and failing to deserialize on every retry > until it dead-letters. `ToPayloadJson()` serialises by runtime type. @@ -1993,3 +1993,73 @@ one. > were still blocked, and patterns from `.gitignore` and a developer's > `.git/info/exclude` were honoured as leakwatch's. It is evaluated in isolation > now. +> +> **Five more review rounds ran after this record was first written**, and they +> are part of the packet rather than a sequel to it — the packet closed when PR +> #13 merged on 2026-08-27, not when the record was drafted. What they found +> divides cleanly in two, and both halves are the same lesson the packet keeps +> teaching: a guarantee is worth what its evidence is worth. +> +> **A budget that was not a budget, in three successive shapes.** The factory +> timeout ended the flight with `TrySetCanceled`, so every waiter — including one +> whose own token was healthy — was told *it* had cancelled; ASP.NET reads a +> cancellation as a client hang-up, so the timeout an operator needs to see would +> have produced no body, no captured error and no span. Faulting with a +> `TimeoutException` exposed the next layer: `CancelAfter` cancels a *token*, and +> a factory that never observes one — the ordinary shape of any dependency call +> that does not thread it — ran to completion regardless. Measured against a +> 150 ms budget, the caller waited 3,002 ms and was handed the late value, which +> means the timeout branch just added was unreachable on exactly the path that +> most needs it. Racing the deadline fixed it and required `Flight.Overrunning`: +> waiting on the completion instead would have spun the retry loop hot, because a +> terminal flight satisfies it instantly. Then the value itself: `factoryTimeout` +> reached `Flight` unvalidated, and `CancelAfter` answers the three bad values +> three different ways — negative throws, but from inside the first cache miss +> rather than at the wiring that was wrong; zero is accepted and cancels +> immediately, making the cache a permanent `TimeoutException` generator; and +> `Timeout.InfiniteTimeSpan` is accepted and never fires, which is the deadline +> silently not existing, reached through configuration this time instead of +> through an uncooperative factory. One `<= TimeSpan.Zero` check at construction +> covers all three. +> +> **A span that reported success for a delivery that failed.** The in-process +> transport logged handler failures at `Error` and let the consumer activity end +> `Unset`, so an operator filtering the trace backend for errors found a green +> span beside the error log describing the same delivery. The activity now covers +> construction, invocation and the await. Publish-token cancellation stays +> `Unset` deliberately — shutdown is not a failure, and marking it would put one +> `Error` span per in-flight subscription into the 100%-sampled error traces +> every time the host stops. +> +> **Three test-side defects of the kind this packet names as its main lesson.** A +> concurrency test whose whole point was detecting overlapping factories used +> `Interlocked.Exchange(ref max, Math.Max(max, current))` — read, compute, write +> as three steps, so the lower result can land last and the overlap disappears. A +> mutation harness reported a mutant that failed to *compile* as SURVIVED, twice, +> because it grepped only for test failures; the second time the mutant was +> genuinely invalid — CA2208 refuses a `paramName` that names no parameter, which +> is a stronger guard than any test. And a mutant aimed at the consumer span's +> cancellation branch initially hit the wrong method's `catch`, so it survived +> for a reason that had nothing to do with the code under test. +> +> **Documentation defects that were each a contradiction inside one file.** +> `architecture/15` and the `wire-dapr-pubsub` skill both handed `envelope.Event` +> to a generic Dapr publish overload — its declared type is `IIntegrationEvent` +> by ADR-0038's design, so `TData` infers to the interface and the publish drops +> every concrete field, the exact loss `ToPayloadJson()` documents as measured +> two paragraphs above the snippet reintroducing it. `architecture/29` claimed +> the cache implementation prefixes keys while its own § 3 explains why prefixing +> would emit `{tenant}:{tenant}:{module}:{name}`. `standards/12` stated Vault +> storage and a Vault watcher as current behaviour four lines under the bullet +> calling Vault a Phase 11 target. `architecture/05` invalidated the entitlement +> cache on a "Dapr pub/sub event" in the list whose first bullet gates Dapr to +> Phase 11. Earlier rounds found the same shape in `architecture/09`, +> `architecture/24`, `10-cross-module-contracts`, `phase-02b`, the glossary and +> `local-dev-setup`. A skill that had its drifted copy of the topic regex removed +> kept the sentence telling authors to keep that copy aligned. +> +> **What was deliberately not done.** ADR-0006 and ADR-0010 carry the same stale +> publish sketch in ASCII flow diagrams, and ADR-0022's Decision outcome still +> names the superseded `hub:host:{host}` key. Accepted ADR bodies are immutable, +> the superseding records already govern both points, and a dated Amendment for a +> diagram would cost more than the drift does. From a5dfab5721fe2f8c3811946b00e8f6e6ddc28b6b Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 27 Aug 2026 18:15:57 +0300 Subject: [PATCH 02/35] docs(decisions): decide the concurrency token and the unit of work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packet 6 writes the first migration, and three questions had to be answered before it could: which optimistic-concurrency token the project uses, what IUnitOfWork wraps, and whether the system actor needs a users row. All three were load-bearing and none was decided anywhere. ADR-0039 takes row_version bigint (CLR long), project-wide. The corpus had deferred the choice in writing — "pick one project-wide" — and three shipped artefacts then picked differently: bigint in the DDL, uint in the kernel, long in Packet 4's already-published EntityTag surface. Two PostgreSQL properties were measured against postgres:18.4-alpine rather than recalled, and the widely cited one is false: VACUUM FREEZE does NOT change xmin (753 before, 753 after). A dump/restore does (753 -> 757), and that is the property that decides it, because the token is in a client's hands through If-Match. ADR-0040 takes one DbConnection per scope, owned by IUnitOfWork. The reason is not cross-module writes — those stay forbidden by Standards 01 and ADR-0010 — but reads: SET LOCAL is connection-local, so a DbContext on its own connection never saw it and returns zero rows under the corrected RLS policy, silently. The ADR also defines what the earlier draft left out: nesting, connection ownership and disposal, the complete set of app.tenant_id setters, and the event-consumer entry point, which never reaches MediatR and therefore never reaches TransactionBehavior. ADR-0038 Amendment 1 withdraws the system-actor seed. Its premise is a foreign key that appears in no document and no source file, and whose absence 31-audit-subsystem actively depends on for GDPR erasure. UserId.SystemActor is a CLR constant; it needs no row. Each ADR names the carriers that still state the withdrawn answer instead of claiming the propagation is done. Packet 6 step 1 makes those edits. Co-Authored-By: Claude Opus 5 (1M context) --- ...-cross-cutting-port-and-event-contracts.md | 54 ++- .../0039-optimistic-concurrency-token.md | 236 ++++++++++++ docs/decisions/0040-ambient-unit-of-work.md | 339 ++++++++++++++++++ docs/decisions/README.md | 2 + 4 files changed, 630 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/0039-optimistic-concurrency-token.md create mode 100644 docs/decisions/0040-ambient-unit-of-work.md diff --git a/docs/decisions/0038-cross-cutting-port-and-event-contracts.md b/docs/decisions/0038-cross-cutting-port-and-event-contracts.md index 41bb36b8..298f8886 100644 --- a/docs/decisions/0038-cross-cutting-port-and-event-contracts.md +++ b/docs/decisions/0038-cross-cutting-port-and-event-contracts.md @@ -158,7 +158,59 @@ required only where the event type declares it. ## Amendments -None. +### Amendment 1 — The system actor needs no `users` row (2026-08-27) + +§ Consequences above says "The Tenancy migration must seed `UserId.SystemActor` +(`00000000-0000-7000-8000-000000000001`) before a persisted consumer can write audit +foreign keys." **The foreign key it names does not exist, and must not.** The clause is +withdrawn. + +Verified across the corpus and the shipped code: `REFERENCES users` appears in no +document and no source file. The canonical tenant-owned template declares +`created_by uuid NOT NULL` with no `REFERENCES` clause +([Database Standards](../standards/05-database.md)), and `audit_log`'s own DDL declares +`actor_user_id uuid NULL` with none either. + +The absence is load-bearing rather than accidental. +[31-audit-subsystem.md](../architecture/31-audit-subsystem.md) depends on it for GDPR +erasure: once the `users` row is erased the audit row's actor is "an orphan surrogate key +with no path back to a natural person, which is what keeps the audit row's existence +auditable after erasure". An enforced foreign key — under any `ON DELETE` action — makes +that state unreachable: `RESTRICT` blocks the erasure, `CASCADE` destroys the audit +trail, `SET NULL` erases the distinction between "system actor" and "unknown actor". + +What the constant is actually for stands unchanged: `AuditableEntity.MarkCreated` refuses +`default(UserId)` and `Guid.Empty` alike, so a non-request execution needs a concrete +`UserId` to pass. `UserId.SystemActor` supplies exactly that, as a CLR constant. It needs +no database row, in Packet 6 or ever. + +Consequently: + +- **Phase 02a Packet 6 seeds nothing and creates no `users` table.** Its migration + creates exactly the ten tables its scope names, which keeps + [Phase 02a](../roadmap/phase-02a-kernel-tenancy.md)'s ten-table completion criterion + literally true and introduces no table outside the three declared RLS classes. +- **`users` is created by the first Identity migration in + [Phase 03](../roadmap/phase-03-identity-admin.md)**, which owns the table. Whether that + migration also inserts a row describing the system actor is Phase 03's decision and is + presentational — nothing depends on its existence. +- `created_by` / `updated_by` / `deleted_by` remain plain `uuid` columns with no + referential constraint, project-wide. + +**Three carriers still state the withdrawn premise, and this amendment is not +complete until they are corrected.** Packet 6 step 1 owns the edits; naming them +here is what keeps the amendment from being one more voice in a corpus that +answers the question twice: + +| Carrier | What it still says | +|---|---| +| [Glossary — `UserId.SystemActor`](../glossary.md) | "It must have a matching `users` row before a persisted consumer can write an audit foreign key. The Tenancy schema and that seed are owned by Phase 02a Packet 6" | +| [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) | "**Seed the system actor.** … It is a foreign key: this packet's migration seeds the matching `users` row so `created_by` resolves." The same packet entry lists exactly ten tables and `users` is not among them, so the roadmap already contradicts itself | +| `LearnStack.SharedKernel/Identifiers/UserId.cs` | "The value is fixed rather than generated, because it is a foreign key. Phase 02a Packet 6 owns the matching Tenancy seed" | + +The rest of this ADR is unaffected: the envelope contract, the consumer identity split +(`UserId.SystemActor` as the effective principal, `CausalActorUserId` as the human), and +the cache-key rules all stand. ## References diff --git a/docs/decisions/0039-optimistic-concurrency-token.md b/docs/decisions/0039-optimistic-concurrency-token.md new file mode 100644 index 00000000..9b39b604 --- /dev/null +++ b/docs/decisions/0039-optimistic-concurrency-token.md @@ -0,0 +1,236 @@ +# ADR-0039: The Optimistic Concurrency Token + +## Status + +Accepted + +**Date:** 2026-08-27 **Deciders:** @platform + +## Decision Drivers + +- **The choice was deferred in writing, and then made three times by + accident.** [Database Standards § Concurrency](../standards/05-database.md) + says `row_version bigint` "… `xmin`-based tokens are an alternative; pick one + project-wide" and stops there; + [04-technical-architecture.md](../architecture/04-technical-architecture.md) + repeats the same fork twice. In the absence of a decision, three shipped + artefacts each picked differently: the canonical DDL declares + `row_version bigint`, `IOptimisticConcurrency.Version` is `uint`, and Packet + 4's already-published `EntityTag.For(long)` / `SetEntityTag(…, long)` surface + is `long`. Nothing has broken yet only because no table exists. + [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) writes the first + one, and a token type is a column type. +- **The token is client-visible, so it outlives the row's storage.** Packet 4 + shipped ETag / `If-Match` concurrency: the token is minted into a response + header, held by a client for an unbounded time, and echoed back. That makes + its stability a contract with a third party, not an implementation detail. +- **Reversing it later is a destructive change on every mutable table.** Adding + or dropping a concurrency column after rows exist is the two-step deploy + [Database Standards § Migrations](../standards/05-database.md) reserves for + destructive changes, on every tenant-owned table in the system at once. +- **One-way door.** Per + [ADR-0035](0035-demand-gated-infrastructure.md)'s test: added six months from + now, this touches every migration, every entity configuration and every + conditional-request handler already written. + +## Considered Options + +1. **`row_version bigint`, with the kernel widened to `long`** (chosen). The + column stays in the canonical template; `IOptimisticConcurrency.Version` and + `AuditableEntity.Version` move from `uint` to `long`; the increment happens + in the one primitive every audited mutation is routed through. +2. **PostgreSQL's `xmin` system column** (rejected). Mapped through Npgsql's + `UseXminAsConcurrencyToken()`; the kernel keeps `uint`; `row_version` is + deleted from the template. +3. **Both — `xmin` for the tenancy tables, `row_version` for domain tables** + (rejected). + +## Decision + +LearnStack uses an explicit **`row_version bigint`** column as the optimistic +concurrency token, project-wide, on every table whose entity implements +`IOptimisticConcurrency`. The CLR type is `long`. + +The value is incremented in `AuditableEntity`, by the same primitive that stamps +`UpdatedAt` / `UpdatedBy`, so that an audited mutation is a versioned mutation. +Today two methods stamp — `MarkUpdated` and `SoftDelete`, the latter by +assigning the fields itself — and Packet 6 routes both through one primitive +before adding the increment; see § Why `MarkUpdated` and not an interceptor for +why doing it the other way round ships a soft delete that no ETag notices. + +There is no second token type and no per-table exception. + +`xmin` is not used as a concurrency token anywhere. + +## Context + +### What the three-way split actually is + +| Artefact | Type | Where | +|---|---|---| +| The canonical DDL | `row_version bigint NOT NULL DEFAULT 0` | [Database Standards § Tenant-Owned Tables](../standards/05-database.md) | +| The kernel marker | `uint Version` | `LearnStack.SharedKernel/Persistence/IOptimisticConcurrency.cs` | +| The audit base | `uint Version` | `LearnStack.SharedKernel/Domain/AuditableEntity.cs` | +| The shipped HTTP surface | `long` | `LearnStack.Api/Common/EntityTag.cs` | + +`uint` is not an arbitrary third answer — it is the Npgsql convention for an +`xmin` token specifically, which is what makes the split a real fork rather +than a typo. The kernel was written expecting `xmin`; the DDL and the HTTP +surface were written expecting a counter. + +### Why the client-visible token settles it + +Two properties were measured against `postgres:18.4-alpine` rather than +recalled, because the widely-repeated version of each is wrong in one +direction or the other: + +- **`VACUUM FREEZE` does *not* change `xmin`.** Measured: `753` before, `753` + after. Since PostgreSQL 9.4 freezing sets an infomask bit and leaves the + original xmin in the tuple header, so the commonly-cited "freezing rewrites + xmin" objection is false and is *not* a reason to reject option 2. +- **A dump/restore *does* change it.** Measured: `753` before, `757` after the + same row round-tripped through `pg_dump` / `psql`. Logical replication has + the same shape for the same reason — the row is re-inserted by a new + transaction, so it gets that transaction's id. + +That second property is the one that matters, because the token is in a client's +hands. A `row_version` survives a restore, a logical-replication cutover and a +major-version upgrade unchanged, because it is data. An `xmin` does not, +because it is storage metadata about a tuple that no longer exists. After a +restore every outstanding `If-Match` in the wild would compare against a value +that changed for a reason no client can observe, and the failure is a +`412 Precondition Failed` storm indistinguishable from real contention. + +### Why not `xmin`, given that its usual objection was false + +Three remaining reasons, in order of weight: + +1. **It cannot back a client-visible ETag across a maintenance window**, per + the measurement above. +2. **`xid` is 32-bit and wraps.** A token whose value space wraps needs a + comparison the application does not perform — the ETag comparison is + string equality on a formatted number, and a wrapped xid can repeat a value + a client is still holding. +3. **It would rewrite an already-published API surface.** `EntityTag.For(long)`, + `ReadAssertion`, `Evaluate(…, long)` and `SetEntityTag(…, long)` shipped in + Packet 4. Option 1 changes two kernel properties that nothing consumes yet; + option 2 changes the public surface that already does. + +Option 3 was rejected on the same ground both times it was considered: two +token types means two ETag derivations, two concurrency-failure paths, and a +per-table question at every future `[Idempotent]` or `If-Match` endpoint. The +standard's own phrasing — "pick one project-wide" — forecloses it. + +### Why `MarkUpdated` and not an interceptor + +[Database Standards § Audit Columns](../standards/05-database.md) previously +said "a shared EF interceptor populates these on `SaveChanges`", which no +shipped code does and which +[ADR-0033](0033-audit-durability-model.md) contradicts: +`AuditChangeTrackerInterceptor` is the only sanctioned `SaveChanges` +interceptor and it deliberately writes nothing. `AuditableEntity.MarkUpdated` +is the method that already exists and already refuses `default(UserId)`. + +**But it is not currently the only path that stamps an update, and that has to +be fixed before the counter can live there.** `AuditableEntity.SoftDelete` +assigns `UpdatedAt` / `UpdatedBy` **directly** rather than delegating to +`MarkUpdated`. Incrementing the counter inside `MarkUpdated` alone would +therefore leave a soft delete un-versioned: a client holding the pre-delete +ETag would still satisfy `If-Match` on the row it had already deleted, and the +next conditional update would pass a precondition that is no longer true. The +guarantee this ADR wants — *an audited mutation is a versioned mutation* — is +not a property of the shipped code; it is a property Packet 6 has to create, by +routing `SoftDelete` through the same stamp-and-increment primitive. + +`MarkCreated` leaves `Version` at its `0` default; the column's +`DEFAULT 0` and the CLR default agree, so an insert needs no special case. + +## Consequences + +### Positive + +- One token type, one ETag derivation, one concurrency-failure path. +- The token survives restore, logical replication and major-version upgrade, so + a client's `If-Match` means the same thing across a maintenance window. +- Packet 4's shipped `long` ETag surface needs no change. +- The version cannot advance without an audit stamp advancing with it. + +### Negative + +- Every mutable table carries an extra 8-byte column and every update writes it. +- A mutation that bypasses `MarkUpdated` — raw SQL, a bulk `ExecuteUpdate` — + does not advance the token. `xmin` would have covered those for free. The + mitigation is that such writes are already outside the audit trail and + already require review; this ADR does not create the exposure, it declines to + paper over it. +- `IOptimisticConcurrency.Version` and `AuditableEntity.Version` change type + from `uint` to `long`. Nothing consumes them yet, which is precisely why the + change is made now. + +### Neutral + +- `xmin` remains available for diagnostics and for any future read-side + change-detection that is not client-visible. This ADR forecloses it as a + *concurrency token*, not as a column. + +## Implementation Notes + +- **Phase 02a Packet 6, step 2** — widen `IOptimisticConcurrency.Version` and + `AuditableEntity.Version` to `long`; route `SoftDelete` through the same + stamp-and-increment primitive as `MarkUpdated`; declare + `row_version bigint NOT NULL DEFAULT 0` on every mutable tenancy table and + configure it as the EF concurrency token with + `IsConcurrencyToken().ValueGeneratedOnAddOrUpdate()` — **not** `IsRowVersion()`, + which maps to a provider-generated `bytea`/`rowversion` this design does not use. +- **Phase 02a Packet 6, step 1 — the propagation this ADR is not, on its own.** + Until these land the corpus answers the question twice, and an implementer + reading a standard rather than this ADR gets the withdrawn answer: + + | Carrier | What it still says | + |---|---| + | [Database Standards § Concurrency](../standards/05-database.md) | "`row_version bigint` (incremented by an EF interceptor) … `xmin`-based tokens are an alternative; pick one project-wide" — wrong on the mechanism and still offering the rejected option | + | [Database Standards § Audit Columns](../standards/05-database.md) | "A shared EF interceptor populates these on `SaveChanges`" — no such interceptor exists, and [ADR-0033](0033-audit-durability-model.md) reserves the only sanctioned `SaveChanges` interceptor for snapshot capture, which writes nothing | + | [04-technical-architecture.md](../architecture/04-technical-architecture.md) | leaves the fork open twice — "using `xmin` or `row_version` column" and "`row_version` (`xmin` or explicit `bigint` column)" | + | Database Standards / API Standards `**Derives from:**` headers | neither cites this ADR | + | [21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md) | carries no entry for the rule below | + | [Phase 02a § ADR commitments](../roadmap/phase-02a-kernel-tenancy.md) | does not list this ADR | +- **Phase 02a Packet 6** — the two infrastructure tables that carry no + aggregate (`outbox_messages`, `idempotency_keys`) are not + `IOptimisticConcurrency` entities and carry no `row_version`. Their write + paths are a lease and a fencing token respectively + ([ADR-0006](0006-events-and-outbox.md), + [ADR-0037](0037-idempotency-key-contract.md)), which are stronger, not weaker. +- **Every later phase** — a new mutable aggregate inherits the column from the + template. No per-table decision remains. + +## Architecture Tests + +Two rules. Neither exists yet; both are **Phase 02a Packet 6 deliverables**, and +registering them in +[21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md) +is part of shipping them. Naming them here is a commitment, not a description. + +- `Aggregates_With_Optimistic_Concurrency_Map_RowVersion` — every entity + implementing `IOptimisticConcurrency` has its `Version` configured as the + concurrency token against a `row_version` column, and no configuration uses + `IsRowVersion()`. +- `SoftDelete_Advances_The_Row_Version` — a behavioural test, because the + structural one cannot see it: `SoftDelete` must leave `Version` strictly + greater than it was, for the reason in § Why `MarkUpdated` and not an + interceptor. Delete the increment and this test fails; that is the whole + point of writing it down. + +## Amendments + +None. + +## References + +- [ADR-0002 — Initial Architecture](0002-initial-architecture.md) +- [ADR-0003 — Tenant Isolation Defense in Depth](0003-tenant-isolation-defense-in-depth.md) +- [ADR-0031 — PostgreSQL: Start on 18.x](0031-postgresql-major-version.md) +- [ADR-0033 — Audit Durability Model](0033-audit-durability-model.md) +- [ADR-0035 — Demand-Gated Infrastructure](0035-demand-gated-infrastructure.md) +- [ADR-0037 — Idempotency Key Contract](0037-idempotency-key-contract.md) +- [Database Standards](../standards/05-database.md) +- [API Standards § Optimistic Concurrency](../standards/04-api-design.md) diff --git a/docs/decisions/0040-ambient-unit-of-work.md b/docs/decisions/0040-ambient-unit-of-work.md new file mode 100644 index 00000000..a4a890b2 --- /dev/null +++ b/docs/decisions/0040-ambient-unit-of-work.md @@ -0,0 +1,339 @@ +# ADR-0040: The Ambient Unit of Work + +## Status + +Accepted + +**Date:** 2026-08-27 **Deciders:** @platform + +## Decision Drivers + +- **Three documents name `IUnitOfWork` and none says what it wraps.** + [ADR-0033](0033-audit-durability-model.md) calls it "the seam + `TransactionBehavior` uses to open, commit and roll back the ambient + transaction without naming a module's `DbContext`, and through which + `IAuditStore` reaches the ambient connection", and makes it a + [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) deliverable. + [Backend Coding Standards § MediatR pipeline](../standards/02-backend-coding.md) + says step 6 "opens the ambient transaction through `IUnitOfWork`". The + roadmap says the Packet 3 shell "lights up here once the per-module + `DbContext` exists". No document says what the seam owns, how a second + `DbContext` relates to it, or what a nested `BeginAsync` does. The + architecture-test catalogue registers nothing. +- **`SET LOCAL` is connection- *and* transaction-local, which makes the + connection count a correctness property rather than a performance one.** + [ADR-0003 Amendment 3](0003-tenant-isolation-defense-in-depth.md) puts + `app.tenant_id` inside the ambient transaction. A `DbContext` that opened its + own connection never saw that statement, so under the corrected RLS policy + every read through it returns **zero rows** — silently, because a policy that + filters everything is indistinguishable from a table with no matching data. +- **The durable audit write needs the ambient connection, not a context.** + ADR-0033 puts `IAuditStore.WritePendingAsync(uow, ct)` immediately before + `COMMIT` on the same transaction as the business write, as parameterised SQL + against a table no module's `DbContext` maps. It belongs to no module and must + not depend on one. +- **A wrong answer is invisible until Phase 03.** Packet 6 creates + `TenancyDbContext` and nothing else, and never reads a tenant-owned table on a + request path. Every candidate shape passes every test the packet can write. + The cost lands when the second context appears with handlers already written + against the seam. + +## Considered Options + +1. **One connection per request scope, owned by `IUnitOfWork`; every context + and every cross-cutting writer enlists on it** (chosen). +2. **Resolve the owning module from the request type** (rejected). The behavior + inspects the request's declaring assembly and opens on that module's + context. It cannot give `IAuditStore` a connection, and a handler reading + another module's data through an application contract still reads zero rows. +3. **A scoped `IUnitOfWork` per module** (rejected). Option 2 with more moving + parts and the same two failures. +4. **Distributed transactions / `TransactionScope`** (rejected). Two + connections to one database promoted to two-phase commit, to solve a problem + that exists only because there were two connections — and a coordinator + every self-hosted install ([ADR-0020](0020-triple-deployment-hybrid-license.md)) + would then have to carry. + +## Decision + +LearnStack's unit of work is **one database connection per scope**, and +`IUnitOfWork` owns it. + +`IUnitOfWork` is a scoped service holding one `DbConnection` from the +application `NpgsqlDataSource` and, once opened, one `DbTransaction` on it. +Every module `DbContext` resolved in that scope is constructed against that same +connection and enlisted in that same transaction; `IAuditStore` and `IOutbox` +reach the same connection through the same seam. + +```csharp +// LearnStack.SharedKernel.Persistence +public interface IUnitOfWork : IAsyncDisposable +{ + /// The ambient connection. Opened on first access, never before. + DbConnection Connection { get; } + + /// The ambient transaction; null before BeginAsync and after the terminal call. + DbTransaction? Transaction { get; } + + /// True once BeginAsync has been called and not yet resolved. + bool HasActiveTransaction { get; } + + /// Joins the ambient transaction if one is active; otherwise opens it. + /// Returns a handle whose Complete() is a no-op for a joiner — see § Nesting. + Task BeginAsync(CancellationToken ct = default); + + /// Commits. Throws if the transaction is marked rollback-only. + Task CommitAsync(CancellationToken ct = default); + + Task RollbackAsync(CancellationToken ct = default); + + /// Marks the ambient transaction as unable to commit. Irreversible. + void MarkRollbackOnly(); +} +``` + +### What the ambient transaction spans — and what it does not + +It spans **one business module's write, plus the cross-cutting infrastructure +rows that must commit with it, plus any reads**: + +| Inside the transaction | Why | +|---|---| +| One aggregate's write, through its module's `DbContext` | The unit of work | +| The MUST-class `audit_log` row | [ADR-0033](0033-audit-durability-model.md) — it commits with the change it describes or not at all | +| The `outbox_messages` row | [ADR-0006](0006-events-and-outbox.md) — the outbox *is* the transactional boundary | +| Reads through any module's `DbContext`, including another module's application contract | They need the `SET LOCAL` that only exists on this connection | + +It does **not** license a cross-aggregate or cross-module *write*. +[Architecture Standards § Aggregate Ownership](../standards/01-architecture-standards.md) +forbids cross-aggregate writes inside a single transaction and requires an +integration event, and [ADR-0010](0010-cross-module-communication.md) makes the +outbox row the boundary precisely so that extracting a module later changes the +transport and nothing else. **This ADR does not relax either rule.** A handler +that needs a second aggregate changed enqueues an integration event; that +enqueue is a row in `outbox_messages`, which is in the table above, so the +mechanism and this transaction model are the same mechanism. + +The shared connection therefore exists for **reads, audit and outbox** — not to +make a forbidden write legal. Stated the other way round: if the only writes in +a transaction are one aggregate plus `audit_log` plus `outbox_messages`, why +share a connection at all? Because of the read row, and because the two +infrastructure writers are not `DbContext`s. + +### Why this is not the Forbidden-list rule + +[Database Standards § Forbidden](../standards/05-database.md) currently forbids +"Multiple `DbContext` instances within one logical transaction". Taken +literally, a handler that reads through another module's contract violates it. +The rule's target is *two independent contexts each opening its own +transaction* — two connections, two commit points, and a window where one has +committed and the other has not. Enlisting several contexts on one owned +connection removes that window rather than creating it. + +**Packet 6 restates the rule** as: *more than one connection, or more than one +transaction, within one logical transaction.* Module boundaries are unaffected +— they are enforced by assembly references and architecture tests, not by +connection count, and each `DbContext` still maps exactly its own module's +entities. + +### Nesting + +An application contract may reach a second handler through `ISender`, so a +second `BeginAsync` on a live transaction is reachable and must be defined: + +- **The outermost `BeginAsync` owns the transaction.** It opens it and is the + only caller whose `CommitAsync` commits. +- **A nested `BeginAsync` joins.** It returns a handle whose completion is a + no-op; it never commits, never rolls back, and never issues `SET LOCAL` a + second time. +- **A nested failure marks the transaction rollback-only.** `CommitAsync` on a + rollback-only transaction throws rather than committing a partial unit; the + outermost frame rolls back. An inner `Result.Fail` that the outer handler + deliberately absorbs is *not* a failure and does not mark it — only an + exception, or an explicit `MarkRollbackOnly()`, does. +- **Concurrent use of the ambient connection is forbidden.** One connection + means one command at a time; a handler that fans out with `Task.WhenAll` over + two module contexts corrupts the protocol. `Modules_Do_Not_Parallelize_Over_The_Ambient_Connection` + is owed for this. + +### Consumers do not have a request, and still need all of this + +The integration-event path does not go through MediatR: +`InProcessEventBus` creates the per-subscription scope and invokes +`IIntegrationEventHandler.HandleAsync` **directly**, so `TransactionBehavior` +never runs. Yet [Phase 02b](../roadmap/phase-02b-events-auth.md) requires the +inbox check, the business write and the inbox marker to be atomic, and all +three touch tenant-owned tables that need `app.tenant_id`. + +**The transport wraps each delivery in the same shape the behavior uses**: +`BeginAsync` → `SET LOCAL` from the scope's `EventTenantContext` → handler → +`CommitAsync`, with a handler exception rolling back. It is the same +`IUnitOfWork`, the same three statements and the same commit boundary; only the +entry point differs, because there are exactly two entry points into the +application — a MediatR request and an event delivery — and a transaction model +that covers one of them is not a model. + +The alternative considered and rejected: making each handler an adapter that +re-sends an inner MediatR command purely to acquire a transaction. It puts the +whole pipeline — validation, authorization, audit classification — on a path +whose input is already trusted and already audited as the system actor, and it +makes the handler contract a lie about what a handler is. + +Phase 02b implements this; Packet 6 ships the seam it needs. The corresponding +Phase 02b line still says the inbox marker goes in "the same `SaveChanges` as +the business write" — a formulation ADR-0033 **withdrew** in favour of the same +*transaction*. Packet 6 corrects that sentence. + +### Who sets `app.tenant_id`, completely + +The set is closed. Each entry either **is** the ambient transaction or owns a +short transaction of its own on its own connection, because it runs where no +ambient transaction exists yet: + +| Setter | Transaction | Why not `TransactionBehavior` | +|---|---|---| +| `TransactionBehavior` | the ambient one | — the general case | +| The event transport, per delivery | the ambient one | There is no MediatR request; see above | +| `IIdempotencyStore` (durable) | its own short one | A claim is taken **before** the pipeline reaches step 6 ([ADR-0037](0037-idempotency-key-contract.md)) | +| `IAuditStore.WriteStandaloneAsync` | its own short one | ADR-0033: an audit row that must survive the rollback of the thing it describes | +| `IAuditStore.WriteBestEffortAsync` | its own short one | ADR-0033: SHOULD/MAY class, failures logged and dropped | +| The `AuditConfig` override loader | its own short read | An out-of-band cached projection, never a request-path query | + +`app.resolving_host` has exactly one setter — `CachedHostToTenantResolver`, in +its own short read-only transaction — because the host is read *in order to +determine* the tenant ([Database Standards § Table classes](../standards/05-database.md)). + +## Context + +### What Packet 6 can and cannot prove + +Packet 6 ships one context and reads no tenant-owned table on a request path, +so neither the multi-context property nor the zero-rows failure is observable +in it. The packet ships the **shape** — the owned connection, the shared +registration helper, the enlist call site, and the structural tests below — and +the properties become testable in Phase 03, when the second `DbContext` exists. +This is recorded so a reviewer does not read Packet 6's green suite as proof of +a property it cannot exercise. + +## Consequences + +### Positive + +- One transaction, one commit point, one connection — so `SET LOCAL` protects + every statement in the unit rather than the ones that happened to share a + connection. +- `TransactionBehavior` stays generic and never references a module assembly. +- `IAuditStore` gets the ambient connection ADR-0033 requires, with none of the + cross-context machinery that ADR explicitly withdrew. +- The consumer path and the request path have one transaction model, not two. +- No distributed-transaction coordinator in any deployment mode. + +### Negative + +- `DbContext` construction is no longer the EF default: contexts are built + against a supplied connection, so a developer adding a module must use the + shared registration helper rather than `AddDbContext(o => o.UseNpgsql(cs))`. + An architecture test carries this. +- **Nothing may read a tenant-owned table before the transaction opens**, and + the pipeline puts `AuthorizationBehavior` at step 5, *before* step 6. A + capability check (`AuthorizeAsync(actor, permission)`) is unaffected — it + reads no table. But + [Permission Standards § Resource-scope checks](../standards/19-permissions.md) + also describes a **policy class** evaluating a resource against the actor; if + such a class loads the resource itself it would run outside the transaction + and read zero rows. **Packet 7 owns the resolution** — either policy classes + receive an already-loaded aggregate from the handler, or a sanctioned + pre-transaction read is defined — and must not leave it to be discovered by + the first policy class written. +- One connection per scope means a long-running request holds a pooled + connection for its whole life, including across an `await` on an external + provider. `IProviderResilience` bounds that and provider calls belong + outside the transaction, but the coupling is real, and is why the connection + is acquired on first use rather than at scope start. +- Concurrency inside a handler is constrained: no `Task.WhenAll` across two + module contexts. +- The Forbidden-list rule has to be re-read by anyone who learned its old + wording. + +### Neutral + +- `IUnitOfWork` exposes no `SaveChangesAsync`. Contexts save themselves; the + unit of work owns only the transaction boundary. ADR-0033 already withdrew + "the same `SaveChanges`" as the atomicity formulation. +- **Connection ownership.** `NpgsqlUnitOfWork` is `IAsyncDisposable` and is the + sole owner: contexts are constructed with `contextOwnsConnection: false`, so + disposing a `DbContext` does not return the connection to the pool underneath + its siblings. Disposal order is transaction, then connection. Disposal of a + unit of work with a live transaction rolls it back — a scope that ends without + an explicit terminal call has failed, and committing on dispose would commit + work nobody claimed was finished. + +## Implementation Notes + +- **Phase 02a Packet 6, step 1 — the corpus edits this ADR requires, none of + which exist yet:** + - [Database Standards § Forbidden](../standards/05-database.md) — restate the + multiple-`DbContext` rule as stated above, and add this ADR to the + `**Derives from:**` header. + - [21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md) + — register the three rules below. + - [Phase 02b](../roadmap/phase-02b-events-auth.md) — replace "inside the same + `SaveChanges` as the business write" with the ambient-transaction + formulation ADR-0033 substituted for it. + - [Phase 02a § ADR commitments](../roadmap/phase-02a-kernel-tenancy.md) — list + this ADR. +- **Phase 02a Packet 6, step 6** — `IUnitOfWork` and `IUnitOfWorkScope` in + `LearnStack.SharedKernel/Persistence`; `NpgsqlUnitOfWork` in + `LearnStack.Infrastructure/Persistence`; the shared `DbContext` registration + helper; `TenancyDbContext` as its first consumer; and the + `TransactionBehavior` body replacing the Packet 3 shell. The `SET LOCAL` + statements are written here and read `UnresolvedTenantContext` until Packet 7 + populates it — correct and fail-closed, per ADR-0003 Amendment 3's note that + between the two packets no tenant-owned table is read on a request path. +- **Phase 02a Packet 7** — `TenantResolverMiddleware` populates + `ITenantContext`, the `SET LOCAL` statements start carrying real values, the + isolation suite runs as `learnstack_app`, and the resource-scope question in + § Consequences is resolved. +- **Phase 02a Packet 9** — `IAuditStore.WritePendingAsync(uow, ct)` immediately + before `COMMIT`. +- **Phase 02b** — the event transport wraps each delivery as described in + § Consumers, and the inbox marker commits with the business write. +- **Phase 03** — the second module `DbContext`, and with it the two behavioural + tests this ADR's central properties are owed: a cross-module read inside the + ambient transaction returns rows, and an outer failure after an inner write + leaves zero rows in both modules. + +## Architecture Tests + +None of these exist yet. All are **Phase 02a Packet 6 deliverables**, and +registering them in +[21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md) +is part of shipping them. + +- `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork` — no `DbContext` + registration configures its own connection string; every one goes through the + shared helper. +- `TransactionBehavior_Does_Not_Reference_A_Module_Assembly` — the behavior + names `IUnitOfWork` and no `DbContext`. +- `Modules_Do_Not_Parallelize_Over_The_Ambient_Connection` — no module code + passes two `DbContext`-bound operations to `Task.WhenAll` / `Task.WhenAny`. + +## Amendments + +None. + +## References + +- [ADR-0002 — Initial Architecture](0002-initial-architecture.md) +- [ADR-0003 — Tenant Isolation Defense in Depth](0003-tenant-isolation-defense-in-depth.md) +- [ADR-0006 — Events and Outbox](0006-events-and-outbox.md) +- [ADR-0010 — Cross-Module Communication](0010-cross-module-communication.md) +- [ADR-0032 — Exception Handling, Logging, and Observability](0032-exception-handling-logging-and-observability.md) +- [ADR-0033 — Audit Durability Model](0033-audit-durability-model.md) +- [ADR-0037 — Idempotency Key Contract](0037-idempotency-key-contract.md) +- [ADR-0039 — The Optimistic Concurrency Token](0039-optimistic-concurrency-token.md) +- [Architecture Standards § Aggregate Ownership](../standards/01-architecture-standards.md) +- [Backend Coding Standards § MediatR pipeline](../standards/02-backend-coding.md) +- [Database Standards](../standards/05-database.md) +- [Permission Standards § Resource-scope checks](../standards/19-permissions.md) +- [Security Standards § Tenant Context](../standards/11-security.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 07e8df1c..8dd14648 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -50,6 +50,8 @@ Accepted ADRs are not rewritten. A new decision is a new ADR, possibly supersedi | 0036 | [Trusted Inputs for Tenant and Organization Resolution](0036-tenant-resolution-trusted-inputs.md) | Resolution by **agreement, not priority** — every authoritative signal present is resolved independently and the request proceeds only on their intersection; no request header names a tenant or an organization, one header names a **host** over an authenticated hop and LearnStack still resolves it itself; `TenantContextOrigin` caps a host-only context to the `[PublicSurface]` read set; the platform-admin override leaves the resolution model | | 0037 | [What an Idempotency Key Identifies, Owns, and Replays](0037-idempotency-key-contract.md) | A client-chosen key is a **nonce inside a tenant's key space**, not an identity: `(tenant, key)` addresses the record and a fingerprint over organization, principal, method, path, query and body decides whether replaying it answers the question asked; a fencing token owns the claim; capacity is **admission, not eviction**, so nothing unexpired is ever displaced; the guarantee is at-most-once while a claim is live and at-least-once across process death | | 0038 | [Cross-Cutting Port and Event Contracts](0038-cross-cutting-port-and-event-contracts.md) | **Supersedes ADR-0014.** Retains Dapr behind demand gates; fixes the event envelope, handler isolation, trace/audit scope and cache contracts | +| 0039 | [The Optimistic Concurrency Token](0039-optimistic-concurrency-token.md) | An explicit `row_version bigint` (CLR `long`), project-wide, incremented by `AuditableEntity.MarkUpdated`; `xmin` is rejected because the token is client-visible through ETag / `If-Match` and a dump-restore or logical-replication cutover changes it — measured, unlike the widely-cited `VACUUM FREEZE` objection, which does not | +| 0040 | [The Ambient Unit of Work](0040-ambient-unit-of-work.md) | One `DbConnection` per scope, owned by `IUnitOfWork`; every module `DbContext`, `IAuditStore` and `IOutbox` enlists on it, because `SET LOCAL` is connection-local and a context on its own connection reads **zero rows** under the corrected RLS policy. Spans one aggregate's write plus audit plus outbox plus any reads — cross-aggregate writes stay forbidden. Defines nesting, disposal, and the event-consumer entry point that never reaches MediatR | ## Superseded ADRs From 21276b288683ed506227e4b24d6ec62abec69e15 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 27 Aug 2026 18:29:10 +0300 Subject: [PATCH 03/35] docs(standards): make the documents Packet 6 transcribes executable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packet 6 writes the first migration by transcribing this corpus. Four of the things it would have transcribed do not work, and each was measured against postgres:18.4-alpine rather than reasoned about. - gen_uuid_v7() does not exist. `ERROR: function gen_uuid_v7() does not exist`; the built-in is uuidv7(). Six documents and one XML comment named it. ADR-0031 Amendment 1 records the correction and lists every carrier, following the ADR-0003 Amendment 3 precedent of fixing wrong content in place rather than letting it propagate. The Decision is untouched. - The 02-create-roles.sql fence failed on its first statement: `:'migration_pw'` is a psql client variable and the initdb runner binds none. It now reads the four passwords with \getenv, measured working under ON_ERROR_STOP=1 — and an unset variable aborts init rather than creating a passwordless role. - The same fence ended with a GRANT on `courses`, a Phase 05 table. Measured: `relation "courses" does not exist`, which under the entrypoint aborts the whole init, so `make dev` would never come up. - GRANT CONNECT named the literal database `learnstack`, which POSTGRES_DB may override. Every SQL fence in Standards 05 now executes clean as the role that owns it: the four roles, the canonical tenant-owned template, the self-keyed `tenants` policy, the four role-qualified `platform_host_to_tenant` policies, and the new idempotency_keys DDL. The last was also proved behaviourally, connected as learnstack_app: zero rows with no tenant context, one with the right tenant, zero with another, WITH CHECK refusing a foreign tenant_id, the 256 KiB CHECK refusing an oversized body, and DELETE denied. idempotency_keys existed in no table-class list, no GRANT matrix and no DDL. It has all three now, derived column by column from the shipped port rather than invented, with one expiry column serving both the 5-minute lease and the 24-hour retention so a release needs no second code path. The rest reconciles the corpus with ADR-0039, ADR-0040 and ADR-0038 Amendment 1 — the concurrency fork closed in four places, the audit-column interceptor that does not exist, the Forbidden-list rule restated, the withdrawn "same SaveChanges" formulation in two more carriers, the complete app.tenant_id setter set, the system-actor foreign key withdrawn in its three remaining carriers, and seven architecture-test rules registered in the catalogue that the ADRs and Standards 05 were citing into thin air. Six skills a Packet 6 implementer copy-pastes from were wrong in ways that do not compile or do not run: `new Id(v)` against a Vogen private constructor, an invented TenantQueryFilterConvention, `tenants` called conceptually isolated, platform_entitlement_cache exempted from row security, a TestFixture that does not exist, `dotnet ef migrations add` with a timestamp EF prepends itself, and a database-update command that would connect as the runtime role and make it the table owner. 736 tests green, 0 warnings under CI=true, format clean. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-backend-module/SKILL.md | 11 +- .claude/skills/add-ef-migration/SKILL.md | 17 +- .claude/skills/add-integration-test/SKILL.md | 29 ++- .../skills/add-tenant-owned-entity/SKILL.md | 42 +++- .../Identifiers/IGuidFactory.cs | 2 +- .../Identifiers/UserId.cs | 21 +- .../architecture/04-technical-architecture.md | 4 +- docs/architecture/09-tenant-isolation.md | 9 +- docs/architecture/27-custom-domain-tls.md | 45 +++- docs/architecture/31-audit-subsystem.md | 7 +- docs/decisions/0002-initial-architecture.md | 2 +- docs/decisions/0006-events-and-outbox.md | 26 +++ ...0023-strongly-typed-id-source-generator.md | 12 +- .../0031-postgresql-major-version.md | 51 ++++- .../0037-idempotency-key-contract.md | 37 +++- docs/decisions/0040-ambient-unit-of-work.md | 40 ++-- docs/decisions/README.md | 2 +- docs/glossary.md | 2 +- docs/roadmap/phase-02a-kernel-tenancy.md | 26 ++- docs/roadmap/phase-02b-events-auth.md | 6 +- docs/standards/02-backend-coding.md | 2 +- docs/standards/04-api-design.md | 3 +- docs/standards/05-database.md | 209 +++++++++++++++++- docs/standards/06-testing.md | 4 +- docs/standards/11-security.md | 33 ++- docs/standards/18-audit-coverage.md | 12 +- .../21-architecture-tests-catalogue.md | 81 ++++++- 27 files changed, 627 insertions(+), 108 deletions(-) diff --git a/.claude/skills/add-backend-module/SKILL.md b/.claude/skills/add-backend-module/SKILL.md index 1ed3a341..f869e3fa 100644 --- a/.claude/skills/add-backend-module/SKILL.md +++ b/.claude/skills/add-backend-module/SKILL.md @@ -144,9 +144,10 @@ public sealed class DbContext( { protected override void OnModelCreating(ModelBuilder modelBuilder) { - // Tenant + Organization query filters applied via convention to - // [TenantOwned] and [OrganizationScoped] entities — see - // SharedKernel/Conventions/TenantQueryFilterConvention.cs. + // Tenant + Organization query filters are applied PER ENTITY in its + // IEntityTypeConfiguration. There is no TenantQueryFilterConvention — + // that type has never existed. Every_TenantOwned_Entity_HasFilterAndRlsPolicy + // is what makes a forgotten filter fail. modelBuilder.ApplyConfigurationsFromAssembly(typeof(DbContext).Assembly); } } @@ -185,7 +186,9 @@ create the spec files under `docs/modules//`: Module migrations live with the module: ```bash -dotnet ef migrations add Initial__Schema \ +# INTENT ONLY, snake_case: EF prepends the UTC timestamp, producing the +# _ filename Standards 05 specifies. +dotnet ef migrations add create__schema \ --project backend/src/Modules//LearnStack.Modules..Infrastructure \ --startup-project backend/src/LearnStack.Api \ --output-dir Persistence/Migrations diff --git a/.claude/skills/add-ef-migration/SKILL.md b/.claude/skills/add-ef-migration/SKILL.md index 2794859d..af9998b0 100644 --- a/.claude/skills/add-ef-migration/SKILL.md +++ b/.claude/skills/add-ef-migration/SKILL.md @@ -51,7 +51,10 @@ and survive forward-only deploy rules ### Step 1: Generate the migration ```bash -dotnet ef migrations add _ \ +# Pass the INTENT only, in snake_case. EF prepends the UTC timestamp itself, so +# the file lands as _.cs — the format Standards 05 +# specifies. Typing the timestamp as well produces it twice. +dotnet ef migrations add \ --project backend/src/Modules//LearnStack.Modules..Infrastructure \ --startup-project backend/src/LearnStack.Api \ --output-dir Persistence/Migrations @@ -283,10 +286,18 @@ dotnet ef migrations script \ --project backend/src/Modules//LearnStack.Modules..Infrastructure \ --startup-project backend/src/LearnStack.Api -# Apply against a local test DB +# Apply against a local test DB. NOTE THE CONNECTION STRING: migrations connect as +# learnstack_migration, which OWNS every table. Running this through the API's +# runtime configuration would connect as learnstack_app and either fail with +# "permission denied for schema public" or — worse, if someone "fixes" that with a +# grant — make the runtime role the table owner, which is the arrangement +# FORCE ROW LEVEL SECURITY exists to defeat. ConnectionStrings:Migration must never +# appear in API or worker runtime configuration +# (docs/standards/05-database.md § Database roles). dotnet ef database update \ --project backend/src/Modules//LearnStack.Modules..Infrastructure \ - --startup-project backend/src/LearnStack.Api + --startup-project backend/src/LearnStack.Api \ + --connection "$LEARNSTACK_MIGRATION_CONNECTION" ``` Then run the architecture + integration test suite. The Testcontainers integration diff --git a/.claude/skills/add-integration-test/SKILL.md b/.claude/skills/add-integration-test/SKILL.md index 93177cda..d0c55d97 100644 --- a/.claude/skills/add-integration-test/SKILL.md +++ b/.claude/skills/add-integration-test/SKILL.md @@ -2,9 +2,11 @@ name: add-integration-test description: > Write a Testcontainers-backed integration test in - `backend/tests/LearnStack.Tests.Integration` that exercises a real Postgres + - Valkey + (optionally) Dapr stack and asserts behaviour under tenant + organization - context. USE FOR: cross-tenant / cross-org isolation tests (mandatory for every + `backend/tests/LearnStack.Tests.Integration` that exercises a real Postgres — + connected as `learnstack_app`, never as the owner — and asserts behaviour under + tenant + organization context. No Valkey and no Kafka: nothing the backend runs + calls them. The fixture arrives in Phase 02a Packet 6; the isolation suite and + CI's `backend-integration` job activate in Packet 7. USE FOR: cross-tenant / cross-org isolation tests (mandatory for every new `[TenantOwned]` / `[OrganizationScoped]` entity), outbox → consumer round trips, audit-pipeline assertions, RLS-effective-isolation tests. DO NOT USE FOR: pure unit tests (use `LearnStack.Tests.Unit`), architecture tests (use @@ -52,10 +54,23 @@ architecture test) plus any other invariant the change touches. See ### Step 1: Reuse the fixture -The project ships `TestFixture` that: - -- Spins Postgres + Valkey (+ optional Kafka via Dapr) via Testcontainers. -- Applies all migrations (per module). +> **There is no `TestFixture` yet.** `LearnStack.Tests.Integration` today holds +> HTTP tests over `WebApplicationFactory` and touches no container; the +> Testcontainers packages are referenced and unused. Phase 02a **Packet 6** +> introduces the Postgres fixture and **Packet 7** the isolation suite, which is +> also when CI's `backend-integration` job activates. Until then, treat the shape +> below as the target and write HTTP-only tests against the existing factory. + +The fixture Packet 6 introduces: + +- Spins **Postgres only** via Testcontainers. Not Valkey, not Kafka — nothing the + backend runs today calls either, and both sit behind the `gated` compose profile + per [ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md). +- Provisions the **four database roles** before the first migration, then applies + migrations **as `learnstack_migration`** — which owns every table — and exposes + a connection as **`learnstack_app`** for the tests themselves. A test that + connects as the owner or as a `BYPASSRLS` role passes even when every policy is + inert, so it proves nothing. - Seeds a baseline platform admin, two tenants, two orgs per tenant. - Exposes `fixture.AsTenant(tenantId, organizationId?)` to scope a block. diff --git a/.claude/skills/add-tenant-owned-entity/SKILL.md b/.claude/skills/add-tenant-owned-entity/SKILL.md index 0ea7b6fe..f9cbceab 100644 --- a/.claude/skills/add-tenant-owned-entity/SKILL.md +++ b/.claude/skills/add-tenant-owned-entity/SKILL.md @@ -6,8 +6,10 @@ description: > `[OrganizationScoped]` markers, EF global query filter, PostgreSQL RLS policy, and an architecture test. USE FOR: any new domain entity that holds tenant data (Course, Enrollment, Cohort, LiveSession, content rows, audit-like rows). DO NOT - USE FOR: global tables (`Tenant`, `User`, `Plan`), Hub-mirrored projection rows, - or pure value objects. + USE FOR: the two tables with their own RLS class — `tenants` (tenant-owned, + self-keyed) and `platform_host_to_tenant` (platform-scoped), both governed by + Database Standards § Table classes — or pure value objects. Note that + `platform_entitlement_cache` IS an ordinary tenant-owned table despite its name. --- # Adding a tenant-owned / org-scoped entity @@ -29,13 +31,25 @@ cross-tenant leak; this skill is the prevention. ## When not to use -- Global tables: `Tenant`, `User`, `Plan`, `Permission` (their isolation is - conceptual, not row-based). +- `tenants` — **not** because its isolation is conceptual. ADR-0003 Amendment 3 + gives it a policy like every other table; it is the **tenant-owned, self-keyed** + class, whose predicate keys on `id` because the primary key *is* the tenant id. + Use [Database Standards § Table classes](../../../docs/standards/05-database.md), + not this skill. +- `platform_host_to_tenant` — the one **platform-scoped** table, with role-qualified + per-command policies, because it is read *in order to determine* the tenant. + Standards 05 again. +- `users`, `plans`, `permissions` — owned by phases that have not written their + schema yet. Nothing here says they are exempt from row security. - The audit aggregate (`AuditEntry`) — it inherits `Entity`, not `AuditableEntity`, and has its own RLS rule. -- Hub-mirrored read-only projections (`platform_entitlement_cache`, - `platform_host_to_tenant`) — they're tenant-id-keyed but written only by - `IEntitlementProvider.RefreshAsync` and `IHostToTenantResolver`. + +`platform_entitlement_cache` is **not** on this list. Its name suggests +platform-scoped and it is not: every read resolves the tenant from `ITenantContext` +first and every write arrives on `PUT /api/internal/tenants/{id}/entitlements`, so +both directions have a tenant and it keeps the ordinary tenant-owned template. An +earlier version of this file exempted it, which would have handed the application +role a table-wide read of every tenant's plan. - Pure value objects with no own table. ## Inputs @@ -114,7 +128,9 @@ public sealed class Configuration : IEntityTypeConfiguration<> builder.ToTable(""); builder.HasKey(x => x.Id); - builder.Property(x => x.Id).HasConversion(id => id.Value, v => new Id(v)); + // Vogen generates a private constructor: `new Id(v)` does not compile. + // `From` is the factory, and it is what runs the validation. + builder.Property(x => x.Id).HasConversion(id => id.Value, v => Id.From(v)); builder.Property(x => x.TenantId).HasConversion(...).IsRequired(); builder.Property(x => x.OrganizationId).HasConversion(...); // omit if not org-scoped @@ -129,7 +145,11 @@ public sealed class Configuration : IEntityTypeConfiguration<> ``` The EF global query filter is applied **by convention**, not in this configuration: -`TenantQueryFilterConvention` (in SharedKernel) scans for `[TenantOwned]` and adds +There is no `TenantQueryFilterConvention` and never has been — do not look for it +and do not import it. The filter is applied per entity in `OnModelCreating`, and +`Every_TenantOwned_Entity_HasFilterAndRlsPolicy` is what makes forgetting one fail. +The paragraph below describes a convention type that would scan for `[TenantOwned]` +and add `x => x.TenantId == _tenantContext.TenantId`. `OrganizationQueryFilterConvention` adds `(x.OrganizationId == null || x.OrganizationId == _tenantContext.OrganizationId)` when `[OrganizationScoped]` is present. **Do not write filters manually** — the @@ -140,7 +160,9 @@ convention is the only legal source. Generate the migration: ```bash -dotnet ef migrations add Add_ \ +# EF prepends its own UTC timestamp, so pass the INTENT only. Passing a timestamp +# too produces 20260827120000_20260827120000_add_.cs. +dotnet ef migrations add add_ \ --project backend/src/Modules//LearnStack.Modules..Infrastructure \ --startup-project backend/src/LearnStack.Api ``` diff --git a/backend/src/LearnStack.SharedKernel/Identifiers/IGuidFactory.cs b/backend/src/LearnStack.SharedKernel/Identifiers/IGuidFactory.cs index 5bf1d3ed..8e915f06 100644 --- a/backend/src/LearnStack.SharedKernel/Identifiers/IGuidFactory.cs +++ b/backend/src/LearnStack.SharedKernel/Identifiers/IGuidFactory.cs @@ -7,7 +7,7 @@ namespace LearnStack.SharedKernel.Identifiers; /// /// Two minting paths exist per ADR-0031 (PostgreSQL 18) + ADR-0023: /// app-side for aggregates that need the ID before -/// SaveChangesAsync, and DB-side gen_uuid_v7() DEFAULT for +/// SaveChangesAsync, and DB-side uuidv7() DEFAULT for /// high-volume append-only tables (audit_log, outbox_messages). /// This factory covers the app-side path only. /// diff --git a/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs b/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs index 18cb82cd..e5c5915a 100644 --- a/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs +++ b/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs @@ -35,12 +35,21 @@ namespace LearnStack.SharedKernel.Identifiers; /// could legally pass and nothing to write at all. /// /// - /// The value is fixed rather than generated, because it is a foreign key. - /// Phase 02a Packet 6 owns the matching Tenancy seed and must create the - /// users row before the first outbox consumer can write audit columns. - /// No Tenancy schema exists before that packet. Version 7 shape with an all-zero random - /// section, so it reads as deliberate in a database dump rather than as a - /// stray identifier somebody forgot to replace. + /// The value is fixed rather than generated so it reads as deliberate in a + /// database dump rather than as a stray identifier somebody forgot to + /// replace — version 7 shape with an all-zero random section. + /// + /// + /// It is not a foreign key, and needs no users row. + /// created_by, updated_by, deleted_by and + /// audit_log.actor_user_id carry no referential constraint anywhere in + /// the schema, and that absence is load-bearing: GDPR erasure leaves the audit + /// row's actor as an orphan surrogate key with no path back to a natural + /// person, which is what keeps the row's existence auditable after erasure. An + /// enforced foreign key would make that state unreachable under every + /// ON DELETE action. See + /// ADR-0038 + /// Amendment 1; the users table itself is owned by Phase 03. /// /// public static UserId SystemActor { get; } = diff --git a/docs/architecture/04-technical-architecture.md b/docs/architecture/04-technical-architecture.md index a9cf6f45..781d2bec 100644 --- a/docs/architecture/04-technical-architecture.md +++ b/docs/architecture/04-technical-architecture.md @@ -146,7 +146,7 @@ Future options (deferred): schema-per-tenant for enterprise tenants, read replic - **Problem Details (RFC 7807)** for all error responses. - **Cursor pagination** for list endpoints. Offset pagination is allowed only for admin-bounded lists. - **Idempotency keys** for `POST` operations that have external side effects (payments, webhooks, send-notification). -- **Optimistic concurrency** for any mutable entity using `xmin` or `row_version` column. +- **Optimistic concurrency** for any mutable entity, on an explicit `row_version bigint` column ([ADR-0039](../decisions/0039-optimistic-concurrency-token.md)). `xmin` is not used as a concurrency token. - **API versioning** via URL prefix: `/api/v1/...`. Breaking changes bump to `/api/v2/...`; non-breaking additions stay on the existing version. See [ADR-0024](../decisions/0024-api-versioning-policy.md), which fixed exactly this `/v1/` vs `/api/v1/` inconsistency. - **Authentication** via OIDC bearer tokens issued by Keycloak. Frontends use Auth.js to bridge. - **Authorization** layered: tenant scope → role/permission → resource ownership where applicable. @@ -161,7 +161,7 @@ GraphQL is not in scope for the MVP. Revisit only when a frontend surface (e.g. | Primary keys | Strongly-typed ids backed by `uuid`. | | Tenant column | `tenant_id uuid not null` on every tenant-owned table; RLS policy enforces it. | | Audit columns | `created_at`, `created_by`, `updated_at`, `updated_by`, optional `deleted_at`, `deleted_by`. | -| Concurrency | `row_version` (`xmin` or explicit `bigint` column) on mutable entities. | +| Concurrency | `row_version bigint` (CLR `long`) on mutable entities, per [ADR-0039](../decisions/0039-optimistic-concurrency-token.md). Not `xmin`. | | Migrations | EF Core migrations; every PR includes a paired migration if the schema changes. | | Soft delete | Opt-in per aggregate; not a global default. | diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index c36b3d7a..ad930c0d 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -112,8 +112,13 @@ Three properties of that template matter to the isolation model described on thi - **An explicit `WITH CHECK`.** `USING` decides what is readable; `WITH CHECK` decides what is writable. -The `app.scope = 'tenant'` setting is set by middleware when the request comes from a -tenant-admin role with a tenant-wide operation flag (e.g. cross-org reporting). It +The `app.scope = 'tenant'` setting is issued by `TransactionBehavior`, alongside +`app.tenant_id` and `app.organization_id` and for the same reason — all three are +transaction-local, so middleware setting them would discard them before the guarded +query ran ([Security Standards § Tenant Context](../standards/11-security.md) is the +single authority). What middleware contributes is the *input*: the request comes from a +tenant-admin role carrying a tenant-wide operation flag (e.g. cross-org reporting), and +the behavior turns that into the session variable. It widens **reads** across organizations within the caller's tenant; it never widens writes, and it never crosses a tenant boundary. The default scope (`null` or `'organization'`) restricts reads to the caller's organization plus tenant-wide rows. diff --git a/docs/architecture/27-custom-domain-tls.md b/docs/architecture/27-custom-domain-tls.md index 10d17100..0dde0633 100644 --- a/docs/architecture/27-custom-domain-tls.md +++ b/docs/architecture/27-custom-domain-tls.md @@ -228,21 +228,46 @@ public sealed class CachedHostToTenantResolver( { public Task ResolveAsync(string host, CancellationToken ct = default) => cache.GetOrSetAsync( - $"host:{host}", - async token => await db.HostMappings - .AsNoTracking() - .Where(m => m.Host == host && m.IsActive) - .Select(m => new HostResolution(m.TenantId, m.OrganizationId)) - .SingleOrDefaultAsync(token), + // Composed by the factory, never interpolated: CacheKey.EnsureValid is + // what stops an unnormalized spelling creating a parallel entry. + CacheKey.ForHostMapping(host), + async token => + { + // The policy on this table admits exactly the row the resolver + // ANNOUNCES. Without the SET LOCAL the predicate is NULL and the + // query returns nothing, so the miss path opens its own + // transaction: SET LOCAL outside a transaction block emits + // "WARNING: SET LOCAL can only be used in transaction blocks" and + // has no effect, and a session-level set_config(..., false) would + // survive on a pooled connection into the next request. + await using var tx = await db.Database.BeginTransactionAsync(token); + + await db.Database.ExecuteSqlAsync( + $"SET LOCAL app.resolving_host = {host}", token); + + var resolution = await db.HostMappings + .AsNoTracking() + .Where(m => m.Host == host && m.IsPubliclyLive) + .Select(m => new HostResolution(m.TenantId, m.OrganizationId)) + .SingleOrDefaultAsync(token); + + await tx.CommitAsync(token); + return resolution; + }, new CacheOptions(L1Ttl: TimeSpan.FromMinutes(2), L2Ttl: TimeSpan.FromMinutes(15)), ct); } ``` -`platform_host_to_tenant` is a **platform-level table**, not tenant-owned — the row is -what *determines* the tenant, so it cannot be filtered by a tenant context that does not -exist yet. It is read before `ITenantContext` is populated, and the read is the only -database access in the request that legitimately runs unscoped. +`platform_host_to_tenant` is the one **platform-scoped** table — the row is what +*determines* the tenant, so it cannot be filtered by a tenant context that does not +exist yet. That does **not** make the read unscoped: row security is enabled and forced +here as everywhere else, and the read is keyed on `app.resolving_host`, which the +resolver declares for exactly the host it is about to resolve +([Database Standards § Table classes](../standards/05-database.md)). The failure mode of +forgetting the `SET LOCAL` is an empty result and a 404 — never a wider read. Writes stay +tenant-keyed, so a session that can see another tenant's host through +`app.resolving_host` still cannot repoint it. Consequences of the change: diff --git a/docs/architecture/31-audit-subsystem.md b/docs/architecture/31-audit-subsystem.md index 999125eb..229692a1 100644 --- a/docs/architecture/31-audit-subsystem.md +++ b/docs/architecture/31-audit-subsystem.md @@ -427,8 +427,11 @@ through the pipeline; the commit outcome travels back out. public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken ct) { - if (!RequiresTransaction(request)) return await next(); - + // No gate. Every request that reaches step 6 needs a transaction: the only + // two exemptions Standards 02 recognises — a forbidden request and a + // validation-failed one — short-circuit at steps 4 and 5 and never arrive + // here. An earlier draft called a RequiresTransaction(request) predicate + // that is defined nowhere and would be a third exemption if it were. await unitOfWork.BeginTransactionAsync(ct); // First statement inside the transaction, per ADR-0003 Amendment 3. await unitOfWork.SetTenantContextAsync(tenantContext, ct); diff --git a/docs/decisions/0002-initial-architecture.md b/docs/decisions/0002-initial-architecture.md index 39f6f9ba..26af1595 100644 --- a/docs/decisions/0002-initial-architecture.md +++ b/docs/decisions/0002-initial-architecture.md @@ -75,7 +75,7 @@ pre-implementation, so the migration drag is zero): change — it is the RESP-provider identifier, not a vendor brand. 2. **PostgreSQL major pinned to 18.x** per [ADR-0031](0031-postgresql-major-version.md). 18 is the longest- - runway LTS available (EOL 2030-11), brings native `gen_uuid_v7()` + runway LTS available (EOL 2030-11), brings native `uuidv7()` that [ADR-0023](0023-strongly-typed-id-source-generator.md) adopts without an extension, and async I/O for sequential scans helps the partitioned `audit_log` diff --git a/docs/decisions/0006-events-and-outbox.md b/docs/decisions/0006-events-and-outbox.md index c9dc1e5d..eb68b94d 100644 --- a/docs/decisions/0006-events-and-outbox.md +++ b/docs/decisions/0006-events-and-outbox.md @@ -77,3 +77,29 @@ Integration event types are `sealed record` inheriting `IntegrationEventBase` (i (`UserCreatedIntegrationEventV2`) and the producer migrates within one deployment window. Outbox stores `EventType` as the assembly-qualified name for version disambiguation. + +--- + +## Amendment 2 — The canonical outbox DDL lives in Standards 05 (2026-08-27) + +This ADR's sketches predate the outbox table's canonical DDL and use two spellings +that table does not have: **`retry_count`** (the column is `attempts`) and +**`EventType`** (the column is `type`). The Decision — an outbox row written in the +same transaction as the aggregate change, dispatched at-least-once, deduplicated by +a consumer-side inbox — is unchanged; only these two names were stale. + +**The canonical DDL is +[Database Standards § Outbox](../standards/05-database.md)** — every column, both +partial indexes, the isolation policy and the three role grants. It is written down +in exactly one place for the reason +[ADR-0003 Amendment 3](0003-tenant-isolation-defense-in-depth.md) records: the +previous template lived in four documents and was wrong in all four. A document +that needs the shape links there rather than restating it, and this ADR is now one +of them. + +[Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) creates the table; +nothing dispatches from it until [Phase 02b](../roadmap/phase-02b-events-auth.md). +`locked_by` / `locked_until` arrive with the dispatcher in that phase and extend +`learnstack_outbox_admin`'s column-scoped `UPDATE` grant in the same migration — a +column added without extending it fails at runtime with `permission denied for +table`. diff --git a/docs/decisions/0023-strongly-typed-id-source-generator.md b/docs/decisions/0023-strongly-typed-id-source-generator.md index de5585d7..a3292c36 100644 --- a/docs/decisions/0023-strongly-typed-id-source-generator.md +++ b/docs/decisions/0023-strongly-typed-id-source-generator.md @@ -33,8 +33,8 @@ Accepted behind an interface — so the chosen library has to be one we are comfortable depending on for the platform's lifetime, or removable with a one-shot find-and-replace if it goes unmaintained. -- **PostgreSQL 18 native `gen_uuid_v7()` is available** ([ADR-0031](0031-postgresql-major-version.md)). - The emitter has to play well with DB-side `DEFAULT gen_uuid_v7()` as well as +- **PostgreSQL 18 native `uuidv7()` is available** ([ADR-0031](0031-postgresql-major-version.md)). + The emitter has to play well with DB-side `DEFAULT uuidv7()` as well as app-side `Guid.CreateVersion7()` (.NET 9+) — both code paths exist in the codebase (DB-side for high-volume audit / outbox tables; app-side for aggregates that need the ID before flush). @@ -89,7 +89,7 @@ value objects. The choice covers the four-artefact emission requirement, the value-object case, the PostgreSQL 18 DB-side UUIDv7 path (Vogen can wrap any `Guid`, including those minted -by `gen_uuid_v7()`), and a `[Description]`/`[ReadOnly]` annotation surface Roslyn +by `uuidv7()`), and a `[Description]`/`[ReadOnly]` annotation surface Roslyn analyzers can read for additional compile-time rules. ## Context @@ -151,7 +151,7 @@ Three things outweighed the appeal: ### What we explicitly punted on -- **UUIDv7 source.** Both DB-side (`gen_uuid_v7()`) and app-side +- **UUIDv7 source.** Both DB-side (`uuidv7()`) and app-side (`Guid.CreateVersion7()`) are valid; the choice between them is per-aggregate (high- volume insert paths like `audit_log` / `outbox_messages` prefer DB-side, aggregates that need the ID before flush prefer app-side). This stays a Standards 05 (database) @@ -233,13 +233,13 @@ Three things outweighed the appeal: [21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md) when the first ID lands. - **UUIDv7 minting:** - - DB-side (`gen_uuid_v7()` per ADR-0031) for `audit_log`, `outbox_messages`, + - DB-side (`uuidv7()` per ADR-0031) for `audit_log`, `outbox_messages`, `inbox_messages`, `idempotency_keys` — high-volume append-only tables. - App-side (`Guid.CreateVersion7()`) for aggregates that need the ID before `SaveChangesAsync()` to emit domain events / outbox writes referencing the new aggregate ID. - **PostgreSQL 18 alignment:** Vogen-emitted `Guid` wrapper types are wire-compatible - with `uuid` columns; the `gen_uuid_v7()` `DEFAULT` is a server-side concern, + with `uuid` columns; the `uuidv7()` `DEFAULT` is a server-side concern, invisible to Vogen. ## Amendments diff --git a/docs/decisions/0031-postgresql-major-version.md b/docs/decisions/0031-postgresql-major-version.md index 3c400e8e..7322a3ac 100644 --- a/docs/decisions/0031-postgresql-major-version.md +++ b/docs/decisions/0031-postgresql-major-version.md @@ -18,11 +18,11 @@ major-version choice only; the rest of ADR-0002 stands) - **Postgres 18 is the longest-runway LTS available.** EOL 2030-11 versus 16 LTS at 2028-11. Starting on 18 buys an extra two years of upstream patches before any forced major upgrade. -- **`gen_uuid_v7()` is native in 18.** LearnStack's +- **`uuidv7()` is native in 18.** LearnStack's [ADR-0023 (Strongly-typed ID source generator)](0023-strongly-typed-id-source-generator.md) adopts UUIDv7 as the canonical id format (time-ordered, index-friendly). Postgres 18 ships a built-in - `gen_uuid_v7()` SQL function — DB-side DEFAULT values become trivial, + `uuidv7()` SQL function — DB-side DEFAULT values become trivial, the app side keeps the strongly-typed wrapping, no extension is required. Postgres 16 / 17 force a choice between an extension (`pg_uuidv7`) and app-side generation; Postgres 18 closes that gap @@ -97,7 +97,7 @@ extension we picked early diverges from the 18 native function. | 18 feature | LearnStack benefit | |------------|--------------------| -| `gen_uuid_v7()` built-in | ADR-0023 uses DB-side `DEFAULT gen_uuid_v7()` for high-volume append-only tables without committing to an extension | +| `uuidv7()` built-in | ADR-0023 uses DB-side `DEFAULT uuidv7()` for high-volume append-only tables without committing to an extension | | Async I/O for sequential scans | `audit_log` partition scans (ADR-0016) — operator query latency | | OAuth authentication | Optional shortcut for Phase 11 break-glass paths (not adopted today) | | Virtual generated columns | Computed columns for `LocalizedMessage`-like derived data (Phase 02a+) | @@ -168,17 +168,58 @@ preview, that preview already runs on 18 — no major upgrade needed. ADR-0002 Amendment 2 references this decision; doc sweep across Standards 12 / Architecture / Standards 20. - **Phase 02a** (Platform kernel): first EF migration targets Postgres - 18; ADR-0023 adopts UUIDv7 with DB-side `gen_uuid_v7()` as the + 18; ADR-0023 adopts UUIDv7 with DB-side `uuidv7()` as the default-value generator for high-volume append-only tables. - **Phase 11** (production hardening): production sizing, backup cadence, replication topology — all written for 18. +## Amendments + +### Amendment 1 — The built-in is `uuidv7()`, not `gen_uuid_v7()` (2026-08-27) + +This ADR, and five documents repeating it, named PostgreSQL 18's native UUIDv7 +generator **`gen_uuid_v7()`**. No such function exists. Measured against +`postgres:18.4-alpine`: + +``` +SELECT gen_uuid_v7(); --> ERROR: function gen_uuid_v7() does not exist +SELECT uuidv7(); --> 01a04366-8141-753d-a6a4-161239372fd0 +``` + +The **Decision is unchanged** — PostgreSQL 18 is pinned, and one of its reasons +is that it generates UUIDv7 natively without an extension. Only the spelling was +wrong, and it was wrong in the one place a spelling matters: a `DEFAULT` clause +in a migration. [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) +writes the first such clause, which is why this surfaced now. + +Following the precedent of +[ADR-0003 Amendment 3](0003-tenant-isolation-defense-in-depth.md) — where wrong +content inside an Accepted ADR was corrected in place and the correction +recorded as an amendment rather than left to propagate — the name is corrected +at every carrier rather than annotated at each one. The carriers, so the edit is +recorded rather than silent: + +| Carrier | Occurrences | +|---|---| +| This ADR — § Decision Drivers, § Considered Options, § Implementation Notes, § References | 5 | +| [ADR-0023](0023-strongly-typed-id-source-generator.md) — § Decision Drivers, § Decision, § Implementation Notes | 6 | +| [ADR-0002](0002-initial-architecture.md) — the PostgreSQL row | 1 | +| [Backend Coding Standards § Identifiers](../standards/02-backend-coding.md) | 1 | +| [decisions/README.md](README.md) — this ADR's summary row | 1 | +| `LearnStack.SharedKernel/Identifiers/IGuidFactory.cs` — XML remarks | 1 | + +`gen_random_uuid()` is a real function and remains correct where it appears; it +produces a **v4** UUID, which is what [ADR-0023](0023-strongly-typed-id-source-generator.md) +adopted UUIDv7 to avoid for index locality. A `uuid` primary key on a +LearnStack table therefore defaults to `uuidv7()` or is minted app-side through +`IGuidFactory.NewUuidV7()` — never `gen_random_uuid()`. + ## References - [ADR-0002 Initial Architecture](0002-initial-architecture.md) — original PostgreSQL major-version row, now partially superseded. - [ADR-0003 Tenant Isolation Defense in Depth](0003-tenant-isolation-defense-in-depth.md) — RLS pattern unchanged across 16/17/18. - [ADR-0016 Audit Log Subsystem](0016-audit-log-subsystem.md) — partitioned `audit_log` benefits from async I/O. -- [ADR-0023 Strongly-typed ID source generator](0023-strongly-typed-id-source-generator.md) — adopts UUIDv7; PostgreSQL 18's native `gen_uuid_v7()` powers the DB-side default path. +- [ADR-0023 Strongly-typed ID source generator](0023-strongly-typed-id-source-generator.md) — adopts UUIDv7; PostgreSQL 18's native `uuidv7()` powers the DB-side default path. - [Standards 05 — Database](../standards/05-database.md) - [Standards 12 § Database Operations](../standards/12-infrastructure.md) - PostgreSQL 18 release notes: . diff --git a/docs/decisions/0037-idempotency-key-contract.md b/docs/decisions/0037-idempotency-key-contract.md index 23c07c06..091d518f 100644 --- a/docs/decisions/0037-idempotency-key-contract.md +++ b/docs/decisions/0037-idempotency-key-contract.md @@ -28,9 +28,9 @@ Accepted implementation. - **The durable store is a schema change, so its contract has to be right first.** The in-memory default is instance-local and dies with the process. - The Postgres-backed implementation lands with the tenancy schema in Packet 6, - and its columns, its transaction boundary and its RLS policy are this ADR's - decisions in DDL form. Getting the port shape wrong now means a migration + The **table** lands with the tenancy schema in Packet 6, and its columns, its + transaction boundary and its RLS policy are this ADR's decisions in DDL form + (see Amendment 1 for why the *implementation* does not land with it). Getting the port shape wrong now means a migration later against rows that already exist. ## Considered Options @@ -241,10 +241,10 @@ in a process and do not survive into a table. ### The durable store -The Postgres-backed implementation lands in -[Packet 6](../roadmap/phase-02a-kernel-tenancy.md) as a **tenant-owned, -tenant-wide table**, and three things about it are decided here because the -port shape depends on them: +The table lands in [Packet 6](../roadmap/phase-02a-kernel-tenancy.md) as a +**tenant-owned, tenant-wide table**; `PostgresIdempotencyStore` itself lands when +that table's ADR-0035 trigger fires (Amendment 1). Three things about it are +decided here because the port shape depends on them: - **Its own transaction, and its own tenant setting.** A claim is taken before the action runs, so it is outside the MediatR @@ -419,6 +419,29 @@ will replay is worth a log line rather than silence. ## Amendments +### Amendment 1 — Packet 6 ships the table; the store ships on its trigger (2026-08-27) + +§ Decision Drivers and § The durable store said the Postgres-backed +implementation "lands with the tenancy schema in Packet 6". § Implementation +Notes, in the same document, gives the ADR-0035 four-part gating precisely: +owning phase Packet 6, "**which ships the table**", trigger "the first endpoint +carrying `[Idempotent]` … or the first deployment running more than one instance". +[Standards 20 § Demand-gated building blocks](../standards/20-infrastructure-stack.md) +carries the same row. **The gating row is right**; the two prose sentences were +loose, and are corrected above. + +The distinction is the one ADR-0035 exists to draw. The **table** is one-way-door +schema: adding it later means a migration against a system that has already been +answering `[Idempotent]` requests out of an instance-local dictionary. The +**implementation** is additive: it replaces a registration at the composition +root and touches nothing already written. So the table ships now and +`InMemoryIdempotencyStore` stays registered — correct for one instance, wrong for +two, and saying so on its own type — until the trigger fires. + +The canonical DDL is +[Database Standards § Idempotency](../standards/05-database.md), derived column +by column from the shipped port rather than restated here. + None yet. ## References diff --git a/docs/decisions/0040-ambient-unit-of-work.md b/docs/decisions/0040-ambient-unit-of-work.md index a4a890b2..714ab81e 100644 --- a/docs/decisions/0040-ambient-unit-of-work.md +++ b/docs/decisions/0040-ambient-unit-of-work.md @@ -18,7 +18,7 @@ Accepted says step 6 "opens the ambient transaction through `IUnitOfWork`". The roadmap says the Packet 3 shell "lights up here once the per-module `DbContext` exists". No document says what the seam owns, how a second - `DbContext` relates to it, or what a nested `BeginAsync` does. The + `DbContext` relates to it, or what a nested begin does. The architecture-test catalogue registers nothing. - **`SET LOCAL` is connection- *and* transaction-local, which makes the connection count a correctness property rather than a performance one.** @@ -59,6 +59,10 @@ Accepted LearnStack's unit of work is **one database connection per scope**, and `IUnitOfWork` owns it. +The member names below match the reference body in +[31-audit-subsystem.md](../architecture/31-audit-subsystem.md), which was written +against this seam before it was specified. + `IUnitOfWork` is a scoped service holding one `DbConnection` from the application `NpgsqlDataSource` and, once opened, one `DbTransaction` on it. Every module `DbContext` resolved in that scope is constructed against that same @@ -72,15 +76,21 @@ public interface IUnitOfWork : IAsyncDisposable /// The ambient connection. Opened on first access, never before. DbConnection Connection { get; } - /// The ambient transaction; null before BeginAsync and after the terminal call. + /// The ambient transaction; null before the first begin and after the terminal call. DbTransaction? Transaction { get; } - /// True once BeginAsync has been called and not yet resolved. + /// True once a transaction has been opened and not yet resolved. bool HasActiveTransaction { get; } /// Joins the ambient transaction if one is active; otherwise opens it. /// Returns a handle whose Complete() is a no-op for a joiner — see § Nesting. - Task BeginAsync(CancellationToken ct = default); + Task BeginTransactionAsync(CancellationToken ct = default); + + /// Issues SET LOCAL app.tenant_id / app.organization_id / app.scope as the + /// first statement inside the transaction. It lives here, not in the + /// behavior, because it is SQL and Standards 02 keeps SQL out of the + /// Application layer. A no-op for a joiner — see § Nesting. + Task SetTenantContextAsync(ITenantContext context, CancellationToken ct = default); /// Commits. Throws if the transaction is marked rollback-only. Task CommitAsync(CancellationToken ct = default); @@ -139,13 +149,16 @@ entities. ### Nesting An application contract may reach a second handler through `ISender`, so a -second `BeginAsync` on a live transaction is reachable and must be defined: - -- **The outermost `BeginAsync` owns the transaction.** It opens it and is the - only caller whose `CommitAsync` commits. -- **A nested `BeginAsync` joins.** It returns a handle whose completion is a - no-op; it never commits, never rolls back, and never issues `SET LOCAL` a - second time. +second `BeginTransactionAsync` on a live transaction is reachable and must be +defined: + +- **The outermost `BeginTransactionAsync` owns the transaction.** It opens it + and is the only caller whose `CommitAsync` commits. +- **A nested `BeginTransactionAsync` joins.** It returns a handle whose + completion is a no-op; it never commits, never rolls back, and its paired + `SetTenantContextAsync` does nothing — re-issuing `SET LOCAL` inside the same + transaction would let an inner frame silently retarget the outer frame's + tenant. - **A nested failure marks the transaction rollback-only.** `CommitAsync` on a rollback-only transaction throws rather than committing a partial unit; the outermost frame rolls back. An inner `Result.Fail` that the outer handler @@ -166,8 +179,9 @@ inbox check, the business write and the inbox marker to be atomic, and all three touch tenant-owned tables that need `app.tenant_id`. **The transport wraps each delivery in the same shape the behavior uses**: -`BeginAsync` → `SET LOCAL` from the scope's `EventTenantContext` → handler → -`CommitAsync`, with a handler exception rolling back. It is the same +`BeginTransactionAsync` → `SetTenantContextAsync` from the scope's +`EventTenantContext` → handler → `CommitAsync`, with a handler exception rolling +back. It is the same `IUnitOfWork`, the same three statements and the same commit boundary; only the entry point differs, because there are exactly two entry points into the application — a MediatR request and an event delivery — and a transaction model diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 8dd14648..4ba4f395 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -42,7 +42,7 @@ Accepted ADRs are not rewritten. A new decision is a new ADR, possibly supersedi | 0028 | [`audit_log` Partition Management — Hangfire Recurring Job](0028-audit-log-partition-management.md) | Daily `learnstack:audit:partition-management` Hangfire job; create-ahead 2 months; drop only on platform-max retention horizon; row-level purge separate; no `pg_partman` dependency | | 0029 | [Object Storage — SeaweedFS](0029-object-storage-seaweedfs.md) | Self-hosted SeaweedFS behind the existing `IStorageProvider` S3 contract; partially supersedes ADR-0002's MinIO row | | 0030 | [Redis-compatible Store — Valkey](0030-redis-compatible-store-valkey.md) | Valkey (Linux Foundation, BSD-3-Clause) for the cache + Dapr state-store backend; RESP-protocol drop-in; partially supersedes ADR-0002's Redis row | -| 0031 | [PostgreSQL — Start on 18.x](0031-postgresql-major-version.md) | Pin primary RDBMS major version to PostgreSQL 18; native `gen_uuid_v7()` + async I/O + longest LTS runway; partially supersedes ADR-0002's PostgreSQL row | +| 0031 | [PostgreSQL — Start on 18.x](0031-postgresql-major-version.md) | Pin primary RDBMS major version to PostgreSQL 18; native `uuidv7()` + async I/O + longest LTS runway; partially supersedes ADR-0002's PostgreSQL row | | 0032 | [Exception Handling, Logging, and Observability](0032-exception-handling-logging-and-observability.md) | `IExceptionHandler` + 8-step MediatR pipeline + `Result.Fail`-only validation + `DomainException`-is-bug discipline + `IProviderResilience` (Polly v8) + Sentry vs OTel error capture boundary + Serilog primary + `TenantContextSpanProcessor` + `IErrorTrackingProvider` deployment-mode branching | | 0033 | [Audit Durability Model](0033-audit-durability-model.md) | **Supersedes ADR-0016.** MUST-class audit written as durable intent inside the business transaction (fails closed); SHOULD/MAY-class stays best-effort; corrected partitioned `audit_log` primary key; `AuditConfig` cannot remove baseline MUST coverage | | 0034 | [Hub Contract Surface Invariant](0034-hub-contract-surface-invariant.md) | Replaces "closed at four endpoints" with two enforceable invariants (Hub stores no tenant content; every crossing goes through a named adapter); enumerates the real endpoint set; TLS key material leaves the entitlement payload; host resolution never calls the Hub | diff --git a/docs/glossary.md b/docs/glossary.md index 9f6540c8..bc11c474 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -282,7 +282,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **`IntegrationEventEnvelope`** | One integration event plus the dispatch metadata the outbox row carries and the event does not: W3C `CorrelationId`, optional `OrganizationId`, `CausationId`, and causal `ActorUserId`. Its `Topic` and `PartitionKey` are the event's own — both are properties of the event *type* rather than of one delivery, so neither can hold a second answer ([ADR-0038](decisions/0038-cross-cutting-port-and-event-contracts.md)). An event implementing `IOrganizationScopedIntegrationEvent` requires a non-empty organization in its envelope. Metadata describes the *delivery*; the event describes the *fact*. | | **`IPartitionSerializer`** | Runs work sequentially within one partition key and concurrently across different ones — the in-process stand-in for what a broker gives you by assigning a partition to one consumer. It exists so the development transport carries the same ordering guarantee as the durable path rather than a weaker one. Queuing work for the key you are already inside is refused rather than deadlocked: the caller that does it is publishing from inside a handler, which [Standards 20](standards/20-infrastructure-stack.md) forbids. | | **`EventTenantContext`** | The `ITenantContext` a consumer runs under, rebuilt from the envelope by the transport before handler discovery or resolution. A consumer executes outside the request that produced the fact, so there is no ambient context to inherit. The effective principal is `UserId.SystemActor`; an envelope human remains available separately as `CausalActorUserId`. Restoring the tenant and optional organization is what makes query filters and RLS policies evaluate against the right scope. | -| **`UserId.SystemActor`** | The fixed, non-empty `UserId` (`00000000-0000-7000-8000-000000000001`) that integration-event consumers, background jobs and other non-request executions use as their effective audit principal — what [Audit Coverage](standards/18-audit-coverage.md) means by an actor of type `system`. It must have a matching `users` row before a persisted consumer can write an audit foreign key. The Tenancy schema and that seed are owned by [Phase 02a Packet 6](roadmap/phase-02a-kernel-tenancy.md); neither exists yet. | +| **`UserId.SystemActor`** | The fixed, non-empty `UserId` (`00000000-0000-7000-8000-000000000001`) that integration-event consumers, background jobs and other non-request executions use as their effective audit principal — what [Audit Coverage](standards/18-audit-coverage.md) means by an actor of type `system`. It needs **no** `users` row: `created_by` and `actor_user_id` carry no referential constraint anywhere, and [31-audit-subsystem](architecture/31-audit-subsystem.md) depends on that absence so an erased actor leaves an orphan surrogate key rather than an unresolvable delete ([ADR-0038 Amendment 1](decisions/0038-cross-cutting-port-and-event-contracts.md)). The `users` table is owned by [Phase 03](roadmap/phase-03-identity-admin.md). | | **`ICacheService`** | Interface for cache reads / writes. `InMemoryCacheService` today; a Valkey-backed implementation when more than one instance runs concurrently. Tenant keys are `{tenant_id}:{module}:{logical-name}`, or `{tenant_id}:{organization_id}:{module}:{logical-name}` for organization scope, composed by `CacheKey` and enforced by `CacheKey.EnsureValid`. The only platform-wide family is the normalized host map `platform:hub:host-map:{normalized-host}`, composed by `CacheKey.ForHostMapping`; there is no generic platform factory. `RemoveByPrefixAsync` is absent per [ADR-0038](decisions/0038-cross-cutting-port-and-event-contracts.md). Set invalidation uses a caller-owned durable generation key embedded in the key template. | | **`ISecretProvider`** | Interface for secret reads. `ConfigurationSecretProvider` today; the Vault-backed implementation when a production secret must rotate without a redeploy, or more than one operator needs access to production secrets ([ADR-0035](decisions/0035-demand-gated-infrastructure.md) — *not* when a non-development deployment merely exists, which SaaS satisfies on day one). Secret namespace `learnstack/{deployment}/{module}/{key}`. | | **`IEntitlementProvider`** | Interface for the Entitlement Projection source. Implementations: `NullEntitlementProvider` (Development only — all features enabled, no limits), `HubEntitlementProvider` (SaaS / Dedicated, from Phase 02c), `SignedLicenseKeyEntitlementProvider` (Self-Hosted; skeleton from Hub `P02c-6`, hardened in Phase 11). The Hub-backed provider resolves in the normative order `L1 → L2 → platform_entitlement_cache → Hub` and never throws out of a feature-flag check. | diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index a10f25b4..4d34b642 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -355,14 +355,27 @@ projection), `platform_entitlement_cache`, `platform_host_to_tenant`, `idempoten port and an in-memory default that is correct for one instance and wrong for two), and `outbox_messages`. Default-organization seeding at tenant creation. -**Seed the system actor.** `UserId.SystemActor` — the fixed id -`00000000-0000-7000-8000-000000000001` in +**The system actor needs no seed, and this packet creates no `users` table.** +`UserId.SystemActor` — the fixed id `00000000-0000-7000-8000-000000000001` in `LearnStack.SharedKernel.Identifiers` — is what an integration-event consumer, a background job, or any other non-request execution writes state as, per [Audit Coverage](../standards/18-audit-coverage.md)'s actor-of-type-`system` rule. `AuditableEntity.MarkCreated` refuses `default(UserId)` and `Guid.Empty` alike, so -without it no consumer can create an aggregate at all. It is a foreign key: this -packet's migration seeds the matching `users` row so `created_by` resolves. +the constant is what lets a consumer create an aggregate at all — and a CLR +constant is all it needs to be. + +This packet entry previously called it a foreign key and ordered the migration to +seed a matching `users` row. **There is no such foreign key.** `REFERENCES users` +appears in no document and no source file; the canonical template writes +`created_by uuid NOT NULL` with no referential clause, and `audit_log` declares +`actor_user_id uuid NULL` with none either. The absence is deliberate: +[31-audit-subsystem.md](../architecture/31-audit-subsystem.md) depends on the +erased actor becoming an orphan surrogate key, which any `ON DELETE` action would +make unreachable. See +[ADR-0038 Amendment 1](../decisions/0038-cross-cutting-port-and-event-contracts.md). +`users` is created by the first Identity migration in +[Phase 03](phase-03-identity-admin.md), which owns the table. This packet ships +exactly the ten tables listed above. The `Organization` aggregate is declared in `LearnStack.Modules.Tenancy.Domain`, with its EF configuration and its migration on `TenancyDbContext`, per [ADR-0017 Amendment 2 @@ -751,7 +764,8 @@ reads `platform_host_to_tenant` in order to *determine* the tenant. Three classe | Class | Tables | Policy | |---|---|---| -| Tenant-owned | `organizations`, `tenant_domains`, `tenant_locales`, `tenant_settings` (the one org-scoped table — it also takes the two `AS RESTRICTIVE` write guards), `tenant_feature_flags`, `platform_entitlement_cache`, `idempotency_keys`, `outbox_messages` | the corrected template verbatim | +| Tenant-owned, **org-scoped** | `tenant_settings` — the only one in this set | the corrected template in full, including the two `AS RESTRICTIVE` `UPDATE` / `DELETE` guards | +| Tenant-owned, **tenant-wide** | `organizations`, `tenant_domains`, `tenant_locales`, `tenant_feature_flags`, `platform_entitlement_cache`, `idempotency_keys`, `outbox_messages` | the same shape with the organization half of the predicate omitted, and therefore no restrictive guards — these tables have no `organization_id` column to guard | | Tenant-owned, self-keyed | `tenants` | the corrected template with the tenant term keyed on `id`, because the primary key *is* the tenant id | | Platform-scoped | `platform_host_to_tenant` | `ENABLE` + `FORCE` with role-qualified per-command policies: reads keyed on the declared `app.resolving_host` (pre-context, single row) or on `app.tenant_id` (a tenant listing its own hosts); writes keyed on `app.tenant_id` only | @@ -1205,6 +1219,8 @@ Six further decisions were taken during the phase and are Accepted: | [ADR-0035](../decisions/0035-demand-gated-infrastructure.md) | Demand-gated infrastructure | **Accepted** (2026-08-08) | The one-way-door test; ports ship now, adapters ship on a named trigger | | [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) | Trusted inputs for tenant + organization resolution | **Accepted** (2026-08-18, Amendment 1 2026-08-20) | Resolution by **agreement, not priority**; no request header names a tenant, one header names a **host** over an authenticated hop; Amendment 1 corrects the normalization order | | [ADR-0037](../decisions/0037-idempotency-key-contract.md) | What an idempotency key identifies, owns and replays | **Accepted** (2026-08-20) | A key is a **nonce inside a tenant's key space**, not an identity; a fingerprint decides whether a replay answers the question asked; a fencing token owns the claim; capacity is admission, not eviction | +| [ADR-0039](../decisions/0039-optimistic-concurrency-token.md) | The optimistic concurrency token | **Accepted** (2026-08-27) | An explicit `row_version bigint` (CLR `long`) project-wide, incremented by the primitive that stamps the audit columns; `xmin` rejected because the token is client-visible through ETag and a restore or replication cutover changes it | +| [ADR-0040](../decisions/0040-ambient-unit-of-work.md) | The ambient unit of work | **Accepted** (2026-08-27) | One `DbConnection` per scope owned by `IUnitOfWork`; every module `DbContext`, `IAuditStore` and `IOutbox` enlists on it, because `SET LOCAL` is connection-local. Defines nesting, disposal, and the event-consumer entry point that never reaches MediatR | The remaining exit gates (tenant + organization resolution, isolation tests running as `learnstack_app`, the durable audit pipeline, customization runtime read paths, API diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 2f4f081d..69c748c8 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -77,7 +77,11 @@ What lands in this phase is the consumer side. `available_after`, with `attempts` and `last_error` recorded on the row. - **Per-module `inbox_messages` table and `IInboxGuard`.** Every `IIntegrationEventHandler` calls `IsAlreadyProcessedAsync` before business logic and - `MarkAsProcessed` inside the same `SaveChanges` as the business write. Dispatch is + `MarkAsProcessed` inside the same **transaction** as the business write — the + ambient one the transport opens per delivery through `IUnitOfWork` + ([ADR-0040](../decisions/0040-ambient-unit-of-work.md)), not a shared + `SaveChanges`, a formulation [ADR-0033](../decisions/0033-audit-durability-model.md) + withdrew. Dispatch is at-least-once; the inbox is what makes consumption effectively once. - **Tenant and organization context restored from the envelope** in every handler scope, before the inner pipeline runs. A consumer that runs without tenant context writes diff --git a/docs/standards/02-backend-coding.md b/docs/standards/02-backend-coding.md index 94febae3..53ec9b12 100644 --- a/docs/standards/02-backend-coding.md +++ b/docs/standards/02-backend-coding.md @@ -64,7 +64,7 @@ Construction: `Guid.NewGuid()` directly in `Domain` / `Application` code** — Standards 02 § Time bans the symmetric `DateTime.UtcNow` for the same reason (deterministic tests). High-volume append-only tables (`audit_log`, `outbox_messages`) prefer - DB-side `gen_uuid_v7()` (per [ADR-0031](../decisions/0031-postgresql-major-version.md)). + DB-side `uuidv7()` (per [ADR-0031](../decisions/0031-postgresql-major-version.md)). - ID types do **not** expose a `New()` static — explicit `From(guidFactory.NewUuidV7())` at the call site keeps the dependency surface honest. diff --git a/docs/standards/04-api-design.md b/docs/standards/04-api-design.md index fbac672a..97e3b2da 100644 --- a/docs/standards/04-api-design.md +++ b/docs/standards/04-api-design.md @@ -1,7 +1,8 @@ # 04 — API Design Standards **Status:** Active -**Derives from:** [ADR 0002 — Initial Architecture](../decisions/0002-initial-architecture.md), [ADR 0003 — Tenant Isolation Defense in Depth](../decisions/0003-tenant-isolation-defense-in-depth.md), [ADR 0024 — API Versioning Policy](../decisions/0024-api-versioning-policy.md), [ADR 0036 — Trusted Inputs for Tenant and Organization Resolution](../decisions/0036-tenant-resolution-trusted-inputs.md), [ADR 0037 — What an Idempotency Key Identifies, Owns, and Replays](../decisions/0037-idempotency-key-contract.md). +**Derives from:** [ADR 0002 — Initial Architecture](../decisions/0002-initial-architecture.md), [ADR 0003 — Tenant Isolation Defense in Depth](../decisions/0003-tenant-isolation-defense-in-depth.md), [ADR 0024 — API Versioning Policy](../decisions/0024-api-versioning-policy.md), [ADR 0036 — Trusted Inputs for Tenant and Organization Resolution](../decisions/0036-tenant-resolution-trusted-inputs.md), [ADR 0037 — What an Idempotency Key Identifies, Owns, and Replays](../decisions/0037-idempotency-key-contract.md), +[ADR 0039 — The Optimistic Concurrency Token](../decisions/0039-optimistic-concurrency-token.md). REST conventions for LearnStack public and admin APIs. diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 47af63dd..d5ec934c 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -10,7 +10,11 @@ database role model**), (Amendment 1: Dapr pub/sub dispatch transport), [ADR-0038 Cross-Cutting Port and Event Contracts](../decisions/0038-cross-cutting-port-and-event-contracts.md), [ADR-0017 Tenant + Organization Hierarchy](../decisions/0017-tenant-organization-hierarchy.md), -[ADR-0031 PostgreSQL — Start on 18.x](../decisions/0031-postgresql-major-version.md). +[ADR-0031 PostgreSQL — Start on 18.x](../decisions/0031-postgresql-major-version.md) +(Amendment 1: the built-in is `uuidv7()`), +[ADR-0037 Idempotency Key Contract](../decisions/0037-idempotency-key-contract.md), +[ADR-0039 The Optimistic Concurrency Token](../decisions/0039-optimistic-concurrency-token.md), +[ADR-0040 The Ambient Unit of Work](../decisions/0040-ambient-unit-of-work.md). PostgreSQL schema, EF Core, and migration conventions. @@ -167,6 +171,31 @@ The parent therefore carries `UNIQUE (tenant_id, id)` purely to be referenceable way. The cost is one redundant-looking unique index per parent table; the alternative is a class of cross-tenant corruption that no RLS policy can catch. +**One written exception: the self-keyed parent.** `tenants` has no `tenant_id` +column — its `id` *is* the tenant id — so it can carry no `UNIQUE (tenant_id, id)` +and the composite form is not expressible for the one foreign key every other +tenancy table needs. `organizations.tenant_id` and its peers therefore reference +`tenants (id)` with a **single column**, and that is safe on this rule's own +reasoning: the composite form exists because a referential-integrity check runs +with row security bypassed and a single-column child FK could point at another +tenant's row. Here the referencing column *is* `tenant_id`, so pointing +elsewhere would be pointing at a different tenant by definition, which the +child's own `WITH CHECK` already refuses. The exception is exactly one table +wide; a second one is a decision, not a convenience. + +**`ON DELETE RESTRICT`, everywhere, until a phase owns deprovisioning.** A +cascade from `tenants` downward would be a tenant-deletion path nobody has +designed — see the note in § GRANT matrix that tenant hard-deprovisioning has no +owning phase. `RESTRICT` makes the absence loud. + +**The circular reference.** `tenants.default_organization_id` points at +`organizations`, which points back at `tenants`. The column is **nullable**, and +the provisioning transaction inserts the tenant, inserts its default +organization, then `UPDATE`s the tenant — three statements in one transaction +rather than a `DEFERRABLE INITIALLY DEFERRED` constraint, because a deferred +constraint moves the failure to `COMMIT`, where the error names the constraint +and not the statement that broke it. + This block is the **single canonical RLS template**. [ADR-0003 Amendment 3](../decisions/0003-tenant-isolation-defense-in-depth.md), [Tenant Isolation](../architecture/09-tenant-isolation.md) and @@ -263,7 +292,8 @@ migration states which one its table is. | Class | Rule | Tables | |---|---|---| -| **Tenant-owned** | The full template above: `ENABLE` + `FORCE`, one permissive policy `AND`-ing the tenant term with the organization term, explicit `WITH CHECK`, restrictive `UPDATE` / `DELETE` guards when org-scoped | every domain table, plus `organizations`, `tenant_domains`, `tenant_locales`, `tenant_settings` (org-scoped), `tenant_feature_flags`, `platform_entitlement_cache`, `outbox_messages` | +| **Tenant-owned, org-scoped** | The full template above: `ENABLE` + `FORCE`, one permissive policy `AND`-ing the tenant term with the organization term, explicit `WITH CHECK`, **and** the two `AS RESTRICTIVE` `UPDATE` / `DELETE` guards | any domain table carrying `organization_id`, plus `tenant_settings` — the only org-scoped table in the Packet 6 set | +| **Tenant-owned, tenant-wide** | The same shape with the organization half of the predicate omitted, and therefore **no** restrictive guards — there is no organization to guard | `organizations`, `tenant_domains`, `tenant_locales`, `tenant_feature_flags`, `platform_entitlement_cache`, `idempotency_keys`, `outbox_messages` | | **Tenant-owned, self-keyed** | Identical, except the tenant term is `id = …` because the row's primary key *is* the tenant id | `tenants` | | **Platform-scoped** | `ENABLE` + `FORCE`, and role-qualified per-command policies: the read is widened by an explicitly declared non-tenant predicate, writes stay tenant-keyed | `platform_host_to_tenant` | @@ -398,15 +428,35 @@ closed; a fifth role requires an ADR. | `learnstack_platform` | `ConnectionStrings:PlatformAdmin` | API host, worker host | `EnterPlatformAdminScope(reason)` and nothing else | `BYPASSRLS` | | `learnstack_outbox_admin` | `ConnectionStrings:OutboxDispatcher` | the worker host that runs `OutboxProcessor` | `OutboxProcessor` and nothing else | `BYPASSRLS` | +**Passwords arrive from the environment, through `\getenv`.** The four +`:'name'` placeholders below are **psql client variables**, and the +`docker-entrypoint-initdb.d` runner binds none of them: measured, the script as +it stood failed on its first statement with `syntax error at or near ":"`. +`\getenv` reads each one from the container environment, which +[Infrastructure Standards § Local Infrastructure](12-infrastructure.md) already +requires to be `${VAR:-fallback}` in compose with a matching row in +`.env.example`. Measured, it works under the entrypoint's `ON_ERROR_STOP=1`, and +an **unset** variable leaves the placeholder unbound and aborts init rather than +creating a passwordless superuser-adjacent role — failing loud is the point. + ```sql -- One-time provisioning, run once per database before the first migration. Ships as -- infra/compose/postgres-init/02-create-roles.sql in Phase 02a Packet 6. +\getenv migration_pw LEARNSTACK_MIGRATION_PW +\getenv app_pw LEARNSTACK_APP_PW +\getenv platform_pw LEARNSTACK_PLATFORM_PW +\getenv outbox_pw LEARNSTACK_OUTBOX_PW + CREATE ROLE learnstack_migration LOGIN PASSWORD :'migration_pw' NOBYPASSRLS; CREATE ROLE learnstack_app LOGIN PASSWORD :'app_pw' NOBYPASSRLS; CREATE ROLE learnstack_platform LOGIN PASSWORD :'platform_pw' BYPASSRLS; CREATE ROLE learnstack_outbox_admin LOGIN PASSWORD :'outbox_pw' BYPASSRLS; -GRANT CONNECT ON DATABASE learnstack +-- :"db" quotes as an identifier, not a literal: the database name is POSTGRES_DB, +-- which .env.example may override, so hardcoding `learnstack` grants CONNECT on a +-- database that need not exist. +\getenv db POSTGRES_DB +GRANT CONNECT ON DATABASE :"db" TO learnstack_migration, learnstack_app, learnstack_platform, learnstack_outbox_admin; -- Since PostgreSQL 15 the public schema no longer grants CREATE to PUBLIC, and the @@ -420,8 +470,11 @@ GRANT USAGE ON SCHEMA public TO learnstack_app, learnstack_platform, learnstack_outbox_admin; -- Per-table grants are written in the migration that creates the table; see the matrix --- below. There is deliberately no ALTER DEFAULT PRIVILEGES. -GRANT SELECT, INSERT, UPDATE, DELETE ON courses TO learnstack_app; +-- below. There is deliberately no ALTER DEFAULT PRIVILEGES. No grant on a specific +-- table belongs in THIS script: it runs at initdb time, before any table exists, and +-- under ON_ERROR_STOP=1 one `relation "…" does not exist` aborts the whole init and +-- the container never becomes healthy. This fence previously ended with a grant on +-- `courses`, a Phase 05 table; measured, it does exactly that. ``` Migrations connect **as** `learnstack_migration`, so every table it creates is already @@ -452,6 +505,7 @@ privileges implicitly. | `tenant_feature_flags` | `SELECT, INSERT, UPDATE, DELETE` | `SELECT, INSERT, UPDATE, DELETE` | — | | `platform_entitlement_cache` | `SELECT, INSERT, UPDATE` | `SELECT, DELETE` | — | | `platform_host_to_tenant` | `SELECT, INSERT, UPDATE, DELETE` | `SELECT, INSERT, UPDATE, DELETE` | — | +| `idempotency_keys` | `SELECT, INSERT, UPDATE` | `SELECT, DELETE` | — | | `outbox_messages` | `SELECT, INSERT` | `SELECT, DELETE` | `SELECT`, `UPDATE (processed_at, attempts, last_error, available_after)` | Four things the matrix cannot express, and one it must not be asked to: @@ -540,18 +594,54 @@ Soft-deletable aggregates also include: - `deleted_at timestamptz NULL` - `deleted_by uuid NULL` -A shared EF interceptor populates these on `SaveChanges`. +These are populated by `AuditableEntity.MarkCreated` / `MarkUpdated` / +`SoftDelete`, which aggregate methods call with the `IClock` they already +inject. **Not by an EF interceptor.** The only sanctioned `SaveChanges` +interceptor is `AuditChangeTrackerInterceptor`, and per +[ADR-0033](../decisions/0033-audit-durability-model.md) it captures a snapshot +and writes nothing — an interceptor that also stamped audit columns would be +writing on a path that ADR deliberately keeps read-only. ## Concurrency -`row_version bigint` (incremented by an EF interceptor) for optimistic concurrency. `xmin`-based tokens are an alternative; pick one project-wide. +`row_version bigint`, CLR `long`, on every entity implementing +`IOptimisticConcurrency`. Configure it with +`IsConcurrencyToken().ValueGeneratedOnAddOrUpdate()` — **not** `IsRowVersion()`, +which maps to a provider-generated `bytea` this design does not use. + +The value is incremented inside `AuditableEntity`, by the same primitive that +stamps `UpdatedAt` / `UpdatedBy`, so an audited mutation is a versioned +mutation. `SoftDelete` routes through that primitive too; stamping the fields +itself would leave a soft delete un-versioned, and a client holding the +pre-delete ETag would still satisfy `If-Match` on the row it deleted. + +**`xmin` is not used as a concurrency token anywhere**, and is no longer an +alternative. [ADR-0039](../decisions/0039-optimistic-concurrency-token.md) +closed the fork this line used to leave open: the token is client-visible +through ETag / `If-Match`, and a dump-restore or logical-replication cutover +changes `xmin` while leaving `row_version` intact. ## Identifiers -- `uuid` PKs (`gen_random_uuid()`). +- `uuid` PKs, **UUIDv7**, by one of exactly two paths per + [ADR-0023](../decisions/0023-strongly-typed-id-source-generator.md): + - **App-side** for aggregates — `IGuidFactory.NewUuidV7()`, so a test can pin + the value and the id exists before the row does. + - **DB-side** `DEFAULT uuidv7()` for the high-volume append-only tables whose + rows are written by infrastructure rather than by an aggregate: + `audit_log`, `outbox_messages`, `inbox_messages`. +- **Never `gen_random_uuid()`.** It is a real function and produces a **v4** + UUID — random, with none of the index locality UUIDv7 was adopted for. The + built-in is `uuidv7()`; `gen_uuid_v7()` does not exist + ([ADR-0031 Amendment 1](../decisions/0031-postgresql-major-version.md)). - Strongly-typed ids in code via value converters. - No surrogate `int` keys for domain entities. -- Sequence-based ids only for high-write append-only logs (outbox, audit log). +- A table addressed by a natural key gets **no surrogate id at all** — + `idempotency_keys` is keyed `(tenant_id, key)` and generates nothing. ADR-0023's + DB-side list covers the append-only tables that do carry a surrogate id. +- `bigint GENERATED BY DEFAULT AS IDENTITY` where a generated integer key is + genuinely wanted — never `bigserial`, whose sequence needs a `GRANT USAGE` of + its own that an identity column does not. ## Indexes @@ -725,6 +815,94 @@ on developer discipline. The earlier justification for nullability — that APIS so its contract cannot rest on a component that is not there yet. The architecture deep dive lives in [15-event-and-outbox.md](../architecture/15-event-and-outbox.md). +## Idempotency + +`idempotency_keys` is the durable `IIdempotencyStore` +([ADR-0037](../decisions/0037-idempotency-key-contract.md)). Every column below +is forced by the shipped port +(`LearnStack.SharedKernel/Idempotency/IIdempotencyStore.cs`) rather than chosen: +`fingerprint` by `TryClaimAsync`'s third parameter and the `Mismatched` +outcome, `claim_token` by the fence on `Complete` and `Abandon`, `state` by the +tombstone ADR-0037 says is explicitly **not** a release, and the four response +columns by `IdempotentResponse`. + +```sql +-- Tenant-owned, tenant-wide. Addressed by its natural key, so it generates no id. +CREATE TABLE idempotency_keys ( + tenant_id uuid NOT NULL, + key text NOT NULL, -- client-chosen nonce inside the tenant's key space + fingerprint text NOT NULL, -- opaque digest; compared ordinally, never interpreted + claim_token uuid NOT NULL, -- the fence Complete/Abandon must present + state text NOT NULL, -- in_flight | completed | unreplayable + status_code int NULL, -- IdempotentResponse, set only when state = 'completed' + content_type text NULL, + headers jsonb NULL, + body bytea NULL, + claimed_at timestamptz NOT NULL DEFAULT now(), + -- ONE expiry column, deliberately. It is the 5-minute claim lease while in_flight + -- and the 24-hour retention window once the outcome is recorded, so the claim + -- statement's "the existing row has expired" predicate is one comparison at every + -- stage. AbandonAsync sets it to now(), which makes the released row satisfy that + -- same predicate — a release needs no second code path, and learnstack_app never + -- needs DELETE. + expires_at timestamptz NOT NULL, + CONSTRAINT pk_idempotency_keys PRIMARY KEY (tenant_id, key), + CONSTRAINT ck_idempotency_keys_state + CHECK (state IN ('in_flight', 'completed', 'unreplayable')), + -- The 256 KiB replay cap becomes something the database enforces rather than + -- something every caller remembers. + CONSTRAINT ck_idempotency_keys_body_size + CHECK (body IS NULL OR octet_length(body) <= 262144), + -- Matches [Idempotent]'s header bounds, so a key the API accepted always fits. + CONSTRAINT ck_idempotency_keys_key_length + CHECK (length(key) BETWEEN 8 AND 128) +); + +-- Serves both the retention sweep and the per-tenant admission count, tenant first +-- per the composite-index rule. +CREATE INDEX ix_idempotency_keys_tenant_id_expires_at + ON idempotency_keys (tenant_id, expires_at); + +ALTER TABLE idempotency_keys ENABLE ROW LEVEL SECURITY; +ALTER TABLE idempotency_keys FORCE ROW LEVEL SECURITY; + +CREATE POLICY idempotency_keys_isolation ON idempotency_keys + USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid) + WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid); + +-- No DELETE for learnstack_app: a release is an UPDATE that backdates expires_at, and +-- withholding DELETE from the request-path role mirrors outbox_messages. Purging is the +-- audited platform scope's job. +GRANT SELECT, INSERT, UPDATE ON idempotency_keys TO learnstack_app; +GRANT SELECT, DELETE ON idempotency_keys TO learnstack_platform; +``` + +Two properties are contract rather than implementation: + +- **The store runs as `learnstack_app` and sets its own `app.tenant_id`.** A + claim is taken *before* the MediatR pipeline reaches `TransactionBehavior`, so + there is no ambient transaction yet. Each of `TryClaim`, `Complete` and + `Abandon` opens a short transaction whose first statement is the `SET LOCAL` + — one of the sanctioned out-of-band setters in + [ADR-0040](../decisions/0040-ambient-unit-of-work.md). A store reaching for + `learnstack_platform` would be invisible to the isolation suite. +- **Capacity is admission, not eviction.** When a tenant's unexpired-record + count is at its ceiling the store answers `CapacityExhausted`; it never drops + a live record to make room. + +**Packet 6 ships the table; `PostgresIdempotencyStore` ships on its trigger** — +the first endpoint carrying `[Idempotent]`, or the first deployment running more +than one instance, whichever comes first +([ADR-0035](../decisions/0035-demand-gated-infrastructure.md)). The table is +one-way-door schema; the implementation is not, and `InMemoryIdempotencyStore` +remains the registered default until then. + +The retention sweep that deletes rows past `expires_at` is owned by +[Phase 11](../roadmap/phase-11-production-hardening.md), alongside the other +recurring maintenance jobs. Until it runs, rows accumulate and the admission +ceiling is what bounds the table — which is the correct failure, not a silent +one. + ## Raw SQL Allowed when: @@ -772,5 +950,16 @@ Forbidden: string interpolation with non-constant values. - Cross-tenant queries outside platform-admin scope. - `IgnoreQueryFilters()` in non-platform code. - Lazy loading. -- Multiple `DbContext` instances within one logical transaction. +- More than one **connection**, or more than one **transaction**, within one + logical transaction. Several module `DbContext`s enlisted on the *one* + connection `IUnitOfWork` owns are fine and are the house pattern + ([ADR-0040](../decisions/0040-ambient-unit-of-work.md)) — the failure this + rule exists to prevent is two independent contexts each opening their own + transaction, leaving a window in which one has committed and the other has + not. This line previously read "Multiple `DbContext` instances within one + logical transaction", which also forbade the safe shape. +- Reading a tenant-owned table outside the ambient transaction. `SET LOCAL` is + connection- and transaction-local, so such a read returns **zero rows** under + the corrected policy — silently, because a policy that filters everything + looks exactly like a table with no matching data. - Tenant tables without RLS in production migrations (CI rejects). diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index aae454bb..8143b53e 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -60,7 +60,9 @@ belongs to is decided by what it needs, not by what it is about: - **Data tests** — real Postgres + Valkey + SeaweedFS via Testcontainers, one database per test class (or Respawn between tests). Everything that is a property of the schema lives here, and **every tenant-isolation invariant** - does. These arrive with the schema in Packet 7 and run in the separate + does. The schema itself lands in Packet 6; these arrive with the **isolation suite** + in Packet 7 — connected as `learnstack_app`, because a test run as the owner or as a + `BYPASSRLS` role passes against inert policies — and run in the separate `backend-integration` job. Both: real module configuration, no mocked repositories, and coverage of the diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index cc319161..7d5cb2d3 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -253,6 +253,26 @@ consequences follow, and both are load-bearing: shared across transactions, so the value is either absent or — worse — left over from another tenant's transaction. +### The out-of-band setters + +`TransactionBehavior` is the general case, not the only one. Four callers run where +no ambient transaction exists yet, so each owns a **short transaction of its own** +whose first statement is the `SET LOCAL`. The set is closed +([ADR-0040](../decisions/0040-ambient-unit-of-work.md)): + +| Setter | Why it cannot be `TransactionBehavior` | +|---|---| +| The integration-event transport, per delivery | There is no MediatR request: `InProcessEventBus` invokes the handler directly, so no behavior runs. It opens the transaction itself, from the delivery's `EventTenantContext` | +| `IIdempotencyStore` (durable) | A claim is taken **before** the pipeline reaches step 6 ([ADR-0037](../decisions/0037-idempotency-key-contract.md)) | +| `IAuditStore.WriteStandaloneAsync` | An audit row that must survive the rollback of the operation it describes cannot share that operation's transaction ([ADR-0033](../decisions/0033-audit-durability-model.md)) | +| `IAuditStore.WriteBestEffortAsync` | Same shape, SHOULD/MAY class; failures are logged and dropped | +| The `AuditConfig` override loader | An out-of-band cached projection, never a request-path query | + +Every one of them connects as `learnstack_app`. A setter that reached for +`learnstack_platform` would be invisible to the isolation suite, which is the +failure mode [ADR-0003](../decisions/0003-tenant-isolation-defense-in-depth.md) +names by hand. + Because every `current_setting` read is called with its missing-OK argument (`true`) **and** wrapped in `NULLIF(…, '')`, both an unset and a reset variable yield `NULL` and the policy predicate filters the row out. The failure mode is an empty result set, not a @@ -446,10 +466,15 @@ directly. Entries are append-only and queryable by tenant admins for their own t Security-relevant durability rules: -- **MUST-class audit fails closed.** The row is enrolled in the same - `DbContext.SaveChanges` as the business write, so a privileged operation cannot commit - unaudited. It also means the insert runs while `app.tenant_id` is set, which is what - lets Row Level Security accept it. +- **MUST-class audit fails closed.** The row is written on the same **transaction** + as the business write — `AuditLogBehavior` classifies and parks the intent, + `TransactionBehavior` calls `IAuditStore.WritePendingAsync` immediately before + `COMMIT` — so a privileged operation cannot commit unaudited. It also means the + insert runs while `app.tenant_id` is set, which is what lets Row Level Security + accept it. "The same `DbContext.SaveChanges`" was the earlier formulation and + [ADR-0033](../decisions/0033-audit-durability-model.md) **withdrew** it: the + guarantee is the transaction, which is what a reader of `audit_log` observes and + which needs no cross-context machinery. - **A failure to read `AuditConfig` fails closed too.** A tenant override may narrow SHOULD/MAY coverage; it may never remove baseline MUST coverage. - SHOULD/MAY-class audit stays best-effort, and its accepted loss is written down rather diff --git a/docs/standards/18-audit-coverage.md b/docs/standards/18-audit-coverage.md index 7ad797f7..c6a1b394 100644 --- a/docs/standards/18-audit-coverage.md +++ b/docs/standards/18-audit-coverage.md @@ -137,9 +137,15 @@ Rules: ## Storage -- One global `audit_log` table partitioned by `occurred_at` **monthly from day one** - ([ADR-0016](../decisions/0016-audit-log-subsystem.md)). RLS isolates rows by - `tenant_id`; the partition strategy serves retention pruning. +- One global `audit_log` table. Phase 02a Packet 9 ships it **plain and + unpartitioned**; monthly partitioning by `occurred_at`, the retention job and the + lifecycle policy of [ADR-0028](../decisions/0028-audit-log-partition-management.md) + arrive in [Phase 11](../roadmap/phase-11-production-hardening.md). "Monthly from day + one" was the earlier plan and [ADR-0033](../decisions/0033-audit-durability-model.md) + changed it: PostgreSQL has no `ALTER TABLE … PARTITION BY`, so the conversion is a + new table either way, and partitioning on day one would force every partition-key + column into the primary key before the schema that shape has to serve exists. RLS + isolates rows by `tenant_id` in both shapes. - Append-only. The `AuditEntry` aggregate inherits `Entity` **not** `AuditableEntity` and exposes no mutators; `IAuditStore` has no update method; and the runtime database role `learnstack_app` holds no `UPDATE` or `DELETE` privilege on diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index e89f94ee..8d7118c0 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -92,7 +92,7 @@ not implemented is the failure mode this column exists to prevent. ### Implemented today -Twenty-two test methods exist in +Twenty-three test methods exist in [`backend/tests/LearnStack.Tests.Architecture`](../../backend/tests/LearnStack.Tests.Architecture), shipped by [Phase 01](../roadmap/phase-01-repository-tooling.md), [Phase 02a Packets 2–3](../roadmap/phase-02a-kernel-tenancy.md) and Packet 4. @@ -625,6 +625,64 @@ otherwise). - **Status:** **Registered.** - **Phase:** 02a (Packet 6 introduces, Packet 10 closes). +### Persistence: concurrency and the unit of work + +Source: [ADR-0039](../decisions/0039-optimistic-concurrency-token.md), +[ADR-0040](../decisions/0040-ambient-unit-of-work.md). Introduced by +[Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md); the two behavioural +rules that need a second `DbContext` are owed by Phase 03. + +#### `Aggregates_With_Optimistic_Concurrency_Map_RowVersion` + +- **Asserts:** every entity implementing `IOptimisticConcurrency` has its `Version` + configured as the concurrency token against a `row_version` column, and **no** + configuration calls `IsRowVersion()` — which maps to a provider-generated `bytea` + rather than the explicit counter ADR-0039 chose. +- **Source:** ADR-0039; [05-database.md § Concurrency](05-database.md). +- **Type:** xUnit + EF model inspection. **Kind:** structural. +- **Status:** **Registered.** **Phase:** 02a Packet 6. + +#### `SoftDelete_Advances_The_Row_Version` + +- **Asserts:** `AuditableEntity.SoftDelete` leaves `Version` strictly greater than it + was. Behavioural, because the structural rule cannot see it: `SoftDelete` stamps + `UpdatedAt` / `UpdatedBy` itself, so an increment placed only in `MarkUpdated` would + leave a soft delete un-versioned and a client's pre-delete ETag would keep + satisfying `If-Match` on the row it deleted. +- **Source:** ADR-0039 § Why `MarkUpdated` and not an interceptor. +- **Type:** xUnit. **Kind:** behavioural. +- **Status:** **Registered.** **Phase:** 02a Packet 6. + +#### `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork` + +- **Asserts:** no `DbContext` registration configures its own connection string or + calls `UseNpgsql(string)`; every one is built through the shared registration + helper against the connection `IUnitOfWork` owns. A context on its own connection + never saw `SET LOCAL`, so every read through it returns zero rows under the + corrected policy — silently. +- **Source:** ADR-0040; [05-database.md § Forbidden](05-database.md). +- **Type:** xUnit + DI registration inspection. **Kind:** structural. +- **Status:** **Registered.** **Phase:** 02a Packet 6. + +#### `TransactionBehavior_Does_Not_Reference_A_Module_Assembly` + +- **Asserts:** `TransactionBehavior` names `IUnitOfWork` and no `DbContext`, and + `LearnStack.Application` takes no build-time reference to any module assembly. +- **Source:** ADR-0040; ADR-0033. +- **Type:** NetArchTest. **Kind:** structural. +- **Status:** **Registered.** **Phase:** 02a Packet 6. + +#### `Modules_Do_Not_Parallelize_Over_The_Ambient_Connection` + +- **Asserts:** no module code passes two `DbContext`-bound operations to + `Task.WhenAll` / `Task.WhenAny`. One connection means one command at a time; a + handler that fans out corrupts the protocol. +- **Source:** ADR-0040 § Nesting. +- **Type:** Roslyn/NetArchTest. **Kind:** structural. +- **Status:** **Awaiting backfill** — the rule is decided; no module code exists to + violate it yet. **Phase:** 02a Packet 6 registers it; Phase 03 implements it with + the first module that could. + ### Tenancy and isolation Source: [ADR-0003](../decisions/0003-tenant-isolation-defense-in-depth.md) (Amendments @@ -634,6 +692,27 @@ Source: [ADR-0003](../decisions/0003-tenant-isolation-defense-in-depth.md) (Amen Read § What a structural test proves before relying on any row in this section. The first two rows are coverage checks; the last three are the proof. +#### `LearnStack_OutboxAdmin_Role_OnlyUsedBy_OutboxProcessor` + +- **Asserts:** `ConnectionStrings:OutboxDispatcher` is resolved by `OutboxProcessor` + and nothing else. A `GRANT` names a role, not a code path — every handler in the + API process runs as the same role — so code-path confinement of a `BYPASSRLS` + credential is carried here or nowhere. +- **Source:** [05-database.md § GRANT matrix](05-database.md); ADR-0003 Amendment 3. +- **Type:** NetArchTest + DI registration inspection. **Kind:** structural. +- **Status:** **Awaiting backfill** — cited by the standard, no dispatcher yet. + **Phase:** 02b. + +#### `Platform_DataSource_Resolved_Only_By_PlatformAdminScope` + +- **Asserts:** the keyed `NpgsqlDataSource` built from `ConnectionStrings:PlatformAdmin` + is resolvable only by `PlatformAdminScope`. Module code cannot reach the + `BYPASSRLS` credential. +- **Source:** [05-database.md § How `EnterPlatformAdminScope(reason)` reaches + `learnstack_platform`](05-database.md). +- **Type:** NetArchTest + DI registration inspection. **Kind:** structural. +- **Status:** **Awaiting backfill.** **Phase:** 02a Packet 7. + #### `Every_TenantOwned_Entity_HasFilterAndRlsPolicy` - **Asserts:** every entity marked `[TenantOwned]` (or implementing `ITenantOwned`) From 4de25a72b170a4e0a3839b7e9ba0d4498e770a17 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 27 Aug 2026 19:10:07 +0300 Subject: [PATCH 04/35] fix(docs): correct what the Opus round measured wrong in Step 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve agents over six lenses, every finding adversarially verified and the load-bearing ones re-measured here before acting. 68 confirmed, 7 blockers, 8 refuted. The refutations were as useful as the findings — they stopped four "gaps" that Steps 2-4 already own and one that would have added hedging to a true sentence. The worst was mine, in an ADR accepted the same day. ADR-0039 prescribed `IsConcurrencyToken().ValueGeneratedOnAddOrUpdate()` and rejected `IsRowVersion()` because it "maps to a provider-generated bytea". Reproduced independently with EF Core 10 + Npgsql 10 against postgres:18.4-alpine, three contexts over one table: IsConcurrencyToken().ValueGeneratedOnAddOrUpdate() before=Ignore after=Ignore -> UPDATE widgets SET name = @p0 PERSISTED row_version = 0 IsRowVersion() identical metadata, store=bigint (not bytea) PERSISTED row_version = 0 IsConcurrencyToken() before=Save after=Save -> UPDATE widgets SET name = @p0, row_version = @p1 PERSISTED row_version = 1 So the prescribed form makes EF omit the column entirely: the token never leaves 0, every If-Match compares equal, and a lost update succeeds while reporting success — a mechanism present and inert, which is worse than none. And the bytea rationale was simply false. ADR-0039 Amendment 1 records both with the measurement; the prescription is `IsConcurrencyToken()` alone, and the architecture rule now asserts ValueGenerated=Never rather than the call site, because a structural test can see metadata but not inertness. Six other things that could not have worked: - The host resolver issued `SET LOCAL app.resolving_host = {host}`. PostgreSQL's SET takes no bind parameter — `syntax error at or near "$1"`, measured — so it is set_config(name, value, true), which does. - tenants.default_organization_id was single-column, the exact cross-tenant reference the composite rule exists to close and, unlike the self-keyed case, expressible as composite. Measured: composite blocks tenant A pointing at tenant B's organization; single-column commits it permanently. - tg__organization_id_immutable was named as an enforcement and had no DDL. Written, with IS DISTINCT FROM rather than <>, because the re-parenting move the restrictive guard admits is NULL -> value and <> is NULL there. - ADR-0037's "the claim is one statement" does not decide what it claims: measured, both in-flight and replay return (0 rows). Amendment 2 carries the CTE-free form that does, including the four columns the re-acquire branch must clear — without them a new claim inherits the expired row's status_code. - ADR-0036 names "the normalization CHECK" as a Packet 6 deliverable that was never written. Written as an output constraint with the seven-step algorithm left where it belongs, and measured against punycode, case, trailing dot and port. - add-backend-module showed AddDbContext(UseNpgsql(connectionString)) — the pattern ADR-0040 exists to forbid — and my own add-tenant-owned-entity fix had left four contradictory sentences about query filters in one paragraph. Governance: ADR-0040's Decision section was edited after acceptance without a note. Amendment 1 records it rather than leaving the edit silent, and states that app.scope has no ITenantContext carrier so Packet 7 owns it. ADR-0023 Amendment 1 drops idempotency_keys from the DB-side id list — the table has no id. ADR-0003 stops being the third copy of the table-class list and links to the one that owns it. 736 tests green, 0 warnings under CI=true, format clean. Every SQL fence in Standards 05 still executes as the role that owns it. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-architecture-test/SKILL.md | 13 +- .claude/skills/add-backend-module/SKILL.md | 14 +- .claude/skills/add-ef-migration/SKILL.md | 16 +- .claude/skills/add-integration-test/SKILL.md | 2 +- .claude/skills/add-mediatr-handler/SKILL.md | 12 +- .../skills/add-tenant-owned-entity/SKILL.md | 41 +++-- .claude/skills/run-tests-locally/SKILL.md | 10 +- .claude/skills/seed-tenant/SKILL.md | 2 +- .../wire-cross-cutting-foundation/SKILL.md | 19 +- .../Pipeline/TransactionBehavior.cs | 35 ++-- .../Persistence/IOptimisticConcurrency.cs | 19 +- docs/architecture/27-custom-domain-tls.md | 14 +- docs/architecture/31-audit-subsystem.md | 11 +- .../0003-tenant-isolation-defense-in-depth.md | 8 + ...0023-strongly-typed-id-source-generator.md | 24 ++- .../0031-postgresql-major-version.md | 4 +- .../0037-idempotency-key-contract.md | 41 ++++- .../0039-optimistic-concurrency-token.md | 48 ++++- docs/decisions/0040-ambient-unit-of-work.md | 38 +++- docs/decisions/README.md | 2 +- docs/glossary.md | 2 +- docs/roadmap/phase-02a-kernel-tenancy.md | 13 ++ docs/roadmap/phase-02b-events-auth.md | 12 +- docs/standards/05-database.md | 169 ++++++++++++++---- docs/standards/06-testing.md | 10 +- docs/standards/11-security.md | 28 +-- docs/standards/18-audit-coverage.md | 3 +- .../21-architecture-tests-catalogue.md | 12 +- 28 files changed, 497 insertions(+), 125 deletions(-) diff --git a/.claude/skills/add-architecture-test/SKILL.md b/.claude/skills/add-architecture-test/SKILL.md index 19414d45..80b12580 100644 --- a/.claude/skills/add-architecture-test/SKILL.md +++ b/.claude/skills/add-architecture-test/SKILL.md @@ -144,9 +144,18 @@ When the rule is about migration content (RLS, partition): [Fact] public void Every_TenantOwned_Table_HasRls_With_AppTenantId() { + // RepositoryPaths.BackendSrc() — the shipped helper. A relative "backend/src" + // is resolved against the TEST HOST's working directory (bin/Debug/net10.0), + // where it does not exist, so the query silently yields nothing and the + // foreach below asserts over an empty set: a green test that checks nothing. var migrationFiles = Directory - .GetFiles("backend/src", "*.cs", SearchOption.AllDirectories) - .Where(f => f.Contains("/Migrations/")); + .GetFiles(RepositoryPaths.BackendSrc(), "*.cs", SearchOption.AllDirectories) + .Where(f => f.Contains($"{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}")) + .ToList(); + + // The guard that makes the emptiness observable. Without it this test passes + // before a single migration exists and keeps passing if the path ever breaks. + Assert.NotEmpty(migrationFiles); foreach (var file in migrationFiles) { diff --git a/.claude/skills/add-backend-module/SKILL.md b/.claude/skills/add-backend-module/SKILL.md index f869e3fa..be3fb183 100644 --- a/.claude/skills/add-backend-module/SKILL.md +++ b/.claude/skills/add-backend-module/SKILL.md @@ -102,9 +102,14 @@ public sealed class Module : ILearnStackModule { public void Register(IServiceCollection services, IConfiguration configuration) { - services.AddDbContext<DbContext>(opt => opt - .UseNpgsql(configuration.GetConnectionString("Default")) - .UseSnakeCaseNamingConvention()); + // NOT AddDbContext(... UseNpgsql(connectionString) ...). A context that + // opens its own connection never saw the SET LOCAL that TransactionBehavior + // issues on the ambient one, so every read through it returns ZERO ROWS + // under the corrected RLS policy — silently. Per ADR-0040 every module + // context is built on the connection IUnitOfWork owns, through the shared + // helper, and Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork fails the + // build if you reach for the EF default instead. + services.AddLearnStackDbContext<DbContext>(); services.AddMediatRFromModule(typeof(Module).Assembly); services.AddValidatorsFromAssembly(typeof(Module).Assembly); @@ -147,7 +152,8 @@ public sealed class DbContext( // Tenant + Organization query filters are applied PER ENTITY in its // IEntityTypeConfiguration. There is no TenantQueryFilterConvention — // that type has never existed. Every_TenantOwned_Entity_HasFilterAndRlsPolicy - // is what makes a forgotten filter fail. + // is what WILL make a forgotten filter fail — it is registered in the + // catalogue and implemented in Phase 02a Packet 7, not before. modelBuilder.ApplyConfigurationsFromAssembly(typeof(DbContext).Assembly); } } diff --git a/.claude/skills/add-ef-migration/SKILL.md b/.claude/skills/add-ef-migration/SKILL.md index af9998b0..f3de8cdb 100644 --- a/.claude/skills/add-ef-migration/SKILL.md +++ b/.claude/skills/add-ef-migration/SKILL.md @@ -72,7 +72,7 @@ AddColumn) but does not know about: - RLS enable + policies (you add them manually). - Partition declarations (you add them manually). -- Postgres-specific defaults (`gen_random_uuid()`, `now()`). +- Postgres-specific defaults (`uuidv7()`, `now()`). `gen_random_uuid()` in a generated migration is a **defect**: it produces a v4 UUID with none of the index locality UUIDv7 was adopted for ([Database Standards § Identifiers](../../../docs/standards/05-database.md)). - Strongly-typed id column types — confirm they map to `uuid`. ### Step 3: New table — add the four mandatory pieces @@ -297,9 +297,14 @@ dotnet ef migrations script \ dotnet ef database update \ --project backend/src/Modules//LearnStack.Modules..Infrastructure \ --startup-project backend/src/LearnStack.Api \ - --connection "$LEARNSTACK_MIGRATION_CONNECTION" + --connection "$ConnectionStrings__Migration" ``` +`ConnectionStrings__Migration` is the environment spelling of +`ConnectionStrings:Migration`. Packet 6 adds it to `.env.example` and exports it +from the repo-root `make migrate` target, which Standards 05 names as its only +sanctioned carrier. + Then run the architecture + integration test suite. The Testcontainers integration tests automatically apply migrations on a fresh Postgres; a green run means the migration is consistent. @@ -322,8 +327,11 @@ migration is consistent. fixing late is painful because production may already have leakable rows. - **Wrong session variable name.** `current_setting('app.current_tenant_id')` is silently wrong — RLS returns zero rows because the variable is never set. -- **Editing an applied migration.** EF stores a checksum in `__EFMigrationsHistory`; - editing in place breaks the chain. Add a new migration to fix instead. +- **Editing an applied migration.** `__EFMigrationsHistory` holds only + `MigrationId` and `ProductVersion` — there is no checksum and **nothing detects + the edit**. On a database that already applied the migration your change is a + silent no-op; on a fresh one it runs. The two diverge permanently. Add a new + migration instead. - **Mixing destructive change with non-destructive in one migration.** Split into separate migrations so rollback is granular. - **One migration spanning multiple modules.** Each module owns its own migrations; diff --git a/.claude/skills/add-integration-test/SKILL.md b/.claude/skills/add-integration-test/SKILL.md index d0c55d97..a25f0c4e 100644 --- a/.claude/skills/add-integration-test/SKILL.md +++ b/.claude/skills/add-integration-test/SKILL.md @@ -47,7 +47,7 @@ architecture test) plus any other invariant the change touches. See |-------|----------|-------------| | Scenario | Yes | A short name + setup + act + assert. | | Seed | Yes | Minimum tenants / orgs / users / customization data the scenario needs. | -| Required containers | Yes | Postgres always; add Valkey / Kafka / Meilisearch / LiveKit / SeaweedFS as needed. | +| Required containers | Yes | **Postgres, always and only.** Not Valkey, not Kafka — nothing the backend runs calls them. Meilisearch / LiveKit / SeaweedFS only for a provider-contract test, and only from the phase that ships the adapter. | | Tenant context | Yes | Which tenant + org the act phase runs as. | ## Workflow diff --git a/.claude/skills/add-mediatr-handler/SKILL.md b/.claude/skills/add-mediatr-handler/SKILL.md index c2772871..d328e1a9 100644 --- a/.claude/skills/add-mediatr-handler/SKILL.md +++ b/.claude/skills/add-mediatr-handler/SKILL.md @@ -143,17 +143,21 @@ public sealed class CreateEnrollmentCommandHandler( db.Enrollments.Add(enrollment); + // EventId and OccurredAt are `required` on IntegrationEventBase and + // nothing populates them for you — the initializer does not compile + // without both. They are injected rather than ambient so a test can pin + // them (02-backend-coding.md § Time). OrganizationId is NOT on the event: + // it is delivery metadata and lives on IntegrationEventEnvelope, which + // the OutboxProcessor builds (ADR-0038). await outbox.EnqueueAsync(new EnrollmentCreatedIntegrationEventV1 { + EventId = guidFactory.NewUuidV7(), + OccurredAt = clock.UtcNow, TenantId = tenantContext.TenantId, - OrganizationId = tenantContext.OrganizationId, EnrollmentId = enrollment.Id.Value, LearnerId = request.LearnerId.Value, CourseVersionId = request.CourseVersionId.Value, CohortId = request.CohortId?.Value, - // OccurredAt is auto-populated by IntegrationEventBase — do not set - // it manually. If you need it explicitly, inject IClock and use - // clock.UtcNow per 02-backend-coding.md § Time. }, cancellationToken); await db.SaveChangesAsync(cancellationToken); // atomic: aggregate + outbox row diff --git a/.claude/skills/add-tenant-owned-entity/SKILL.md b/.claude/skills/add-tenant-owned-entity/SKILL.md index f9cbceab..ecad989e 100644 --- a/.claude/skills/add-tenant-owned-entity/SKILL.md +++ b/.claude/skills/add-tenant-owned-entity/SKILL.md @@ -144,16 +144,37 @@ public sealed class Configuration : IEntityTypeConfiguration<> } ``` -The EF global query filter is applied **by convention**, not in this configuration: -There is no `TenantQueryFilterConvention` and never has been — do not look for it -and do not import it. The filter is applied per entity in `OnModelCreating`, and -`Every_TenantOwned_Entity_HasFilterAndRlsPolicy` is what makes forgetting one fail. -The paragraph below describes a convention type that would scan for `[TenantOwned]` -and add -`x => x.TenantId == _tenantContext.TenantId`. `OrganizationQueryFilterConvention` -adds `(x.OrganizationId == null || x.OrganizationId == _tenantContext.OrganizationId)` -when `[OrganizationScoped]` is present. **Do not write filters manually** — the -convention is the only legal source. +**Write the query filter here, in this configuration.** There is no +`TenantQueryFilterConvention` and no `OrganizationQueryFilterConvention` — neither +type has ever existed in this repository, and an earlier version of this file +described both as the only legal source, which sent implementers looking for +something to import: + +```csharp +// Tenant-owned: the filter mirrors the RLS predicate. Both layers, always — +// the filter is what makes an ordinary LINQ query safe, the policy is what +// makes it safe when the filter is bypassed. +builder.HasQueryFilter(x => x.TenantId == tenantContext.TenantId); + +// Org-scoped ADD the organization term to the SAME filter — a second +// HasQueryFilter call REPLACES the first rather than combining with it: +builder.HasQueryFilter(x => + x.TenantId == tenantContext.TenantId + && (x.OrganizationId == null + || x.OrganizationId == tenantContext.OrganizationId)); + +// Soft-deletable aggregates AND the deleted term into the same expression too, +// and gate on DeletedAt rather than the computed IsDeleted property, which EF +// cannot translate. +``` + +`Every_TenantOwned_Entity_HasFilterAndRlsPolicy` is what will make a forgotten +filter fail, and `Every_OrgScoped_Entity_HasOrgIdAndFilter` covers the +organization term. Both are **registered and not yet implemented** — Phase 02a +Packet 7 introduces them, so until then a forgotten filter is caught by review +only. Check the +[catalogue](../../../docs/standards/21-architecture-tests-catalogue.md) rather +than assuming a net is under you. ### Step 3: Migration — schema + RLS diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index 244aa2eb..322424e5 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -60,8 +60,9 @@ docker info >/dev/null && echo "docker OK" backend/tests/ LearnStack.Tests.Unit/ # No DB, no Docker. Pure unit tests. LearnStack.Tests.Architecture/ # Reflection + Roslyn + migration-scan rules. - LearnStack.Tests.Integration/ # Testcontainers (Postgres, Valkey, optional Dapr). - LearnStack.Tests.EndToEnd/ # Real HTTP API + frontend smoke. + LearnStack.Tests.Integration/ # WebApplicationFactory HTTP tests today; Testcontainers + # Postgres (as learnstack_app) from Phase 02a Packet 6. + LearnStack.Tests.Contract/ # OpenAPI / SDK contract assertions. frontend/apps/web/ # Vitest + axe-core + Playwright (E2E). ``` @@ -103,8 +104,9 @@ Common failure messages and fixes: ### Step 5: Run integration tests -Testcontainers spin Postgres + Valkey per class fixture; runtime depends on Docker -performance. +Today these are `WebApplicationFactory` HTTP tests and need no Docker at all. From +Phase 02a Packet 6 a Testcontainers **Postgres** fixture joins them — no Valkey and +no Kafka, because nothing the backend runs calls either ([ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md)). ```bash dotnet test backend/tests/LearnStack.Tests.Integration \ diff --git a/.claude/skills/seed-tenant/SKILL.md b/.claude/skills/seed-tenant/SKILL.md index a4670868..9e88290f 100644 --- a/.claude/skills/seed-tenant/SKILL.md +++ b/.claude/skills/seed-tenant/SKILL.md @@ -143,7 +143,7 @@ When the local Hub stack is up, the seed additionally: - Creates the tenant on Hub via `POST /api/internal/tenants`. - Receives `PUT /api/internal/tenants/{id}/entitlements` push with a default plan. -- `platform_entitlement_cache` is populated by the Dapr event consumer. +- `IEntitlementProvider.RefreshAsync` writes `platform_entitlement_cache` from that push. Nothing writes the table directly, and no Dapr consumer is involved — the transport behind `IEventBus` is `InProcessEventBus` until Phase 11's trigger fires ([ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md)). - `platform_host_to_tenant` gets the slug → tenant mapping. When `--hub-backed=false`, `NullEntitlementProvider` covers entitlement (all diff --git a/.claude/skills/wire-cross-cutting-foundation/SKILL.md b/.claude/skills/wire-cross-cutting-foundation/SKILL.md index 2494145e..491fa684 100644 --- a/.claude/skills/wire-cross-cutting-foundation/SKILL.md +++ b/.claude/skills/wire-cross-cutting-foundation/SKILL.md @@ -310,15 +310,20 @@ place, so behaviors and instrumentation are already wired before module-specific code lights up: ```csharp -services - .AddCrossCuttingFoundation(builder.Configuration, deploymentMode) - .AddModuleAudit() - .AddModuleTenancy() - .AddModuleCustomization() - // ... more modules - .AddModuleApi(); // controllers wire last +// The shipped entry point, and the only one: AddLearnStackCrossCuttingFoundation +// on the WebApplicationBuilder, taking the deployment mode and the assemblies whose +// integration-event handlers should be discovered. +builder.AddLearnStackCrossCuttingFoundation( + deploymentMode, + typeof(SomeConsumer).Assembly); ``` +`AddCrossCuttingFoundation`, `AddModuleAudit`, `AddModuleTenancy`, +`AddModuleCustomization` and `AddModuleApi` do **not** exist — an earlier version of +this file named all five. Per-module registration arrives with the first module that +has something to register (Phase 02a Packet 6 for Tenancy); until then there is one +call. + ## Validation - `dotnet build` succeeds for `LearnStack.Api`. diff --git a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs index e8780387..124b2447 100644 --- a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs @@ -11,13 +11,23 @@ namespace LearnStack.Application.Pipeline; /// requests short-circuit upstream and never open a transaction. /// /// -/// Phase 02a Packet 3 ships the shell: there is no -/// per-module DbContext yet (those land starting in Packet 6 + -/// Phase 03). The shell just delegates to the inner pipeline so the -/// canonical eight-step order can be wired now; Packet 6 swaps the body for -/// the real DbContext.Database.BeginTransactionAsync() + -/// commit-on-success-Result / rollback-on-failure pattern without changing -/// registration order. +/// Phase 02a Packet 3 ships the shell: it delegates to the +/// inner pipeline so the canonical eight-step order can be wired now. Packet 6 +/// replaces the body without changing registration order. +/// +/// +/// +/// The replacement opens the transaction through +/// IUnitOfWork.BeginTransactionAsync and sets the tenant context through +/// IUnitOfWork.SetTenantContextAsyncnot through a +/// DbContext.Database.BeginTransactionAsync(). Per +/// ADR-0040 +/// the unit of work owns one connection per scope and every module +/// DbContext enlists on it; a behavior that reached for a context would +/// have to name a module, which is the thing this seam exists to avoid, and a +/// context on its own connection never sees the SET LOCAL and reads zero +/// rows. +/// /// public sealed class TransactionBehavior : IPipelineBehavior @@ -31,11 +41,12 @@ public Task Handle( { ArgumentNullException.ThrowIfNull(next); - // TODO(2026-05-21, @platform): Phase 02a Packet 6 — open the UoW - // transaction (per-module DbContext.Database.BeginTransactionAsync), - // commit on success-Result, rollback on fail-Result, and rollback + - // rethrow on exception (preserving the rethrow that AuditLogBehavior - // owns one frame out). + // TODO(2026-08-27, @platform): Phase 02a Packet 6 — replace this body per + // ADR-0040: IUnitOfWork.BeginTransactionAsync, then SetTenantContextAsync + // as the first statement inside it, then next(); CommitAsync on a + // success-Result, RollbackAsync on a fail-Result, and rollback + rethrow + // on exception (preserving the rethrow AuditLogBehavior owns one frame + // out). A nested Begin joins the ambient transaction and does not commit. return next(); } diff --git a/backend/src/LearnStack.SharedKernel/Persistence/IOptimisticConcurrency.cs b/backend/src/LearnStack.SharedKernel/Persistence/IOptimisticConcurrency.cs index 91008598..53f10a5c 100644 --- a/backend/src/LearnStack.SharedKernel/Persistence/IOptimisticConcurrency.cs +++ b/backend/src/LearnStack.SharedKernel/Persistence/IOptimisticConcurrency.cs @@ -10,9 +10,22 @@ namespace LearnStack.SharedKernel.Persistence; public interface IOptimisticConcurrency { /// - /// Monotonically-increasing version counter. EF Core bumps this on - /// every SaveChangesAsync; aggregate code does not mutate it - /// directly. + /// Monotonically-increasing version counter, mapped to the + /// row_version bigint column. /// + /// + /// + /// EF Core does not bump this. It is incremented in + /// AuditableEntity, by the same primitive that stamps the audit + /// columns, so an audited mutation is a versioned mutation + /// (ADR-0039). + /// The property is configured with IsConcurrencyToken() and nothing + /// else: adding ValueGeneratedOnAddOrUpdate() — or the equivalent + /// IsRowVersion() — tells EF the database generates the value, and + /// EF then omits the column from the UPDATE entirely. Measured: the + /// persisted value stays 0 for the life of the row and every lost + /// update succeeds. + /// + /// uint Version { get; } } diff --git a/docs/architecture/27-custom-domain-tls.md b/docs/architecture/27-custom-domain-tls.md index 0dde0633..7d146528 100644 --- a/docs/architecture/27-custom-domain-tls.md +++ b/docs/architecture/27-custom-domain-tls.md @@ -242,11 +242,23 @@ public sealed class CachedHostToTenantResolver( // survive on a pooled connection into the next request. await using var tx = await db.Database.BeginTransactionAsync(token); + // set_config(..., true) is SET LOCAL's function form and is + // transaction-local for the same reason. It has to be this form: + // `SET LOCAL app.resolving_host = $1` is a syntax error — + // PostgreSQL's SET takes no bind parameter — so the parameterised + // spelling every other query uses is unavailable here, and string + // interpolation into SET would be an injection site on the + // anonymous page-load path. await db.Database.ExecuteSqlAsync( - $"SET LOCAL app.resolving_host = {host}", token); + $"SELECT set_config('app.resolving_host', {host}, true)", token); var resolution = await db.HostMappings .AsNoTracking() + // is_publicly_live, per ADR-0036 § HostOnly — NOT the + // Hub-side `is_active` in the payload sample below. A + // domain can be active (owned, verified) and not yet + // publicly live, and only the latter may answer an + // anonymous page load. .Where(m => m.Host == host && m.IsPubliclyLive) .Select(m => new HostResolution(m.TenantId, m.OrganizationId)) .SingleOrDefaultAsync(token); diff --git a/docs/architecture/31-audit-subsystem.md b/docs/architecture/31-audit-subsystem.md index 229692a1..b4b6d095 100644 --- a/docs/architecture/31-audit-subsystem.md +++ b/docs/architecture/31-audit-subsystem.md @@ -427,11 +427,12 @@ through the pipeline; the commit outcome travels back out. public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken ct) { - // No gate. Every request that reaches step 6 needs a transaction: the only - // two exemptions Standards 02 recognises — a forbidden request and a - // validation-failed one — short-circuit at steps 4 and 5 and never arrive - // here. An earlier draft called a RequiresTransaction(request) predicate - // that is defined nowhere and would be a third exemption if it were. + // No gate. Everything that reaches step 6 needs a transaction, because the + // requests that must not open one have already short-circuited: validation + // failure at step 1, an unresolved tenant at step 4 (tenant_mismatch), and + // authorization denial at step 5. An earlier draft called a + // RequiresTransaction(request) predicate that is defined nowhere and would + // have been a fourth exemption if it were. await unitOfWork.BeginTransactionAsync(ct); // First statement inside the transaction, per ADR-0003 Amendment 3. await unitOfWork.SetTenantContextAsync(tenantContext, ct); diff --git a/docs/decisions/0003-tenant-isolation-defense-in-depth.md b/docs/decisions/0003-tenant-isolation-defense-in-depth.md index 3b818f6d..d627a79e 100644 --- a/docs/decisions/0003-tenant-isolation-defense-in-depth.md +++ b/docs/decisions/0003-tenant-isolation-defense-in-depth.md @@ -165,6 +165,14 @@ Its binding properties: ### Which tables the template applies to +> **Amendment note (2026-08-27).** The enumeration below was written before +> `idempotency_keys` existed and before the tenant-owned class was split into +> org-scoped and tenant-wide. **[Database Standards § Table classes](../standards/05-database.md) +> is the single authority for the assignment**; this section keeps the *rule* — +> which class a table belongs to and why — and no longer keeps the list, for the +> same reason the template itself lives in one file: a list copied into three +> documents drifts in three directions, and this copy already had. + The corrected template governs **tenant-owned** tables. Two classes sit outside it, and both are enumerated rather than left to judgement: diff --git a/docs/decisions/0023-strongly-typed-id-source-generator.md b/docs/decisions/0023-strongly-typed-id-source-generator.md index a3292c36..bec1ca35 100644 --- a/docs/decisions/0023-strongly-typed-id-source-generator.md +++ b/docs/decisions/0023-strongly-typed-id-source-generator.md @@ -234,7 +234,9 @@ Three things outweighed the appeal: when the first ID lands. - **UUIDv7 minting:** - DB-side (`uuidv7()` per ADR-0031) for `audit_log`, `outbox_messages`, - `inbox_messages`, `idempotency_keys` — high-volume append-only tables. + `inbox_messages` — high-volume append-only tables. (`idempotency_keys` was + listed here and is not one: it is addressed by the natural key + `(tenant_id, key)` and has no surrogate id to generate. See Amendment 1.) - App-side (`Guid.CreateVersion7()`) for aggregates that need the ID before `SaveChangesAsync()` to emit domain events / outbox writes referencing the new aggregate ID. @@ -349,3 +351,23 @@ This is a clarification; the Decision is unchanged. — establishes the `backend/analyzers/` Roslyn analyzer location; future ID-shape analyzers live alongside. - [Vogen on GitHub](https://github.com/SteveDunn/Vogen) — upstream project (MIT). + +--- + +## Amendment 1 — `idempotency_keys` mints no id (2026-08-27) + +§ Implementation Notes listed `idempotency_keys` among the tables whose ids are +minted DB-side with `uuidv7()`. It has no id to mint. The shipped port +(`LearnStack.SharedKernel/Idempotency/IIdempotencyStore.cs`) addresses a record by +`(tenantId, key)` at every one of its three methods, so the canonical DDL in +[Database Standards § Idempotency](../standards/05-database.md) declares +`PRIMARY KEY (tenant_id, key)` and no surrogate column. A generated id would be a +column nothing reads. + +The Decision is unchanged: UUIDv7 for `uuid` primary keys, minted app-side through +`IGuidFactory` for aggregates and DB-side for the high-volume append-only tables. +`idempotency_keys` is neither — it is mutable and naturally keyed — so it was never +in scope for either path. The list is corrected in place, per the precedent in +[ADR-0031 Amendment 1](0031-postgresql-major-version.md). + +[Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) creates the table. diff --git a/docs/decisions/0031-postgresql-major-version.md b/docs/decisions/0031-postgresql-major-version.md index 7322a3ac..9665c050 100644 --- a/docs/decisions/0031-postgresql-major-version.md +++ b/docs/decisions/0031-postgresql-major-version.md @@ -201,8 +201,8 @@ recorded rather than silent: | Carrier | Occurrences | |---|---| -| This ADR — § Decision Drivers, § Considered Options, § Implementation Notes, § References | 5 | -| [ADR-0023](0023-strongly-typed-id-source-generator.md) — § Decision Drivers, § Decision, § Implementation Notes | 6 | +| This ADR — § Decision Drivers, § Context, § Implementation Notes, § References | 5 | +| [ADR-0023](0023-strongly-typed-id-source-generator.md) — § Decision Drivers, § Decision, § Context, § Implementation Notes | 6 | | [ADR-0002](0002-initial-architecture.md) — the PostgreSQL row | 1 | | [Backend Coding Standards § Identifiers](../standards/02-backend-coding.md) | 1 | | [decisions/README.md](README.md) — this ADR's summary row | 1 | diff --git a/docs/decisions/0037-idempotency-key-contract.md b/docs/decisions/0037-idempotency-key-contract.md index 091d518f..77e91083 100644 --- a/docs/decisions/0037-idempotency-key-contract.md +++ b/docs/decisions/0037-idempotency-key-contract.md @@ -442,7 +442,46 @@ The canonical DDL is [Database Standards § Idempotency](../standards/05-database.md), derived column by column from the shipped port rather than restated here. -None yet. +### Amendment 2 — The claim statement, corrected (2026-08-27) + +§ The durable store said "**The claim is one statement.** `INSERT … ON CONFLICT +(tenant_id, key) DO UPDATE … WHERE RETURNING …` +decides acquire versus in-flight versus replay in a single round trip." Measured +against the canonical DDL as `learnstack_app`: **it does not.** When the `DO +UPDATE`'s `WHERE` is false PostgreSQL performs no update and `RETURNING` emits +nothing, so *blocked by a live claim* and *blocked by a completed row* are both +`(0 rows)` and indistinguishable — and neither surfaces the stored `fingerprint` +that `Mismatched` needs or the four response columns a replay needs. + +The decision the statement realises is unchanged. The statement is: + +```sql +INSERT INTO idempotency_keys + (tenant_id, key, fingerprint, claim_token, state, expires_at) +VALUES (@tenant, @key, @fingerprint, @token, 'in_flight', now() + interval '5 minutes') +ON CONFLICT (tenant_id, key) DO UPDATE SET + -- Fires unconditionally so RETURNING always has a row; the expiry test moves + -- into the SET expressions. + fingerprint = CASE WHEN idempotency_keys.expires_at <= now() THEN EXCLUDED.fingerprint ELSE idempotency_keys.fingerprint END, + claim_token = CASE WHEN idempotency_keys.expires_at <= now() THEN EXCLUDED.claim_token ELSE idempotency_keys.claim_token END, + state = CASE WHEN idempotency_keys.expires_at <= now() THEN 'in_flight' ELSE idempotency_keys.state END, + expires_at = CASE WHEN idempotency_keys.expires_at <= now() THEN EXCLUDED.expires_at ELSE idempotency_keys.expires_at END, + -- The re-acquire branch MUST clear the previous outcome. Measured: without + -- these four the new claim inherits the expired row's status_code and a later + -- replay answers with a response this request never produced. + status_code = CASE WHEN idempotency_keys.expires_at <= now() THEN NULL ELSE idempotency_keys.status_code END, + content_type = CASE WHEN idempotency_keys.expires_at <= now() THEN NULL ELSE idempotency_keys.content_type END, + headers = CASE WHEN idempotency_keys.expires_at <= now() THEN NULL ELSE idempotency_keys.headers END, + body = CASE WHEN idempotency_keys.expires_at <= now() THEN NULL ELSE idempotency_keys.body END +RETURNING (xmax = 0) AS inserted, state, fingerprint, claim_token, + status_code, content_type, headers, body; +``` + +`xmax = 0` distinguishes a fresh insert from a conflict resolution, and the +returned `state` and `fingerprint` decide the rest: `Acquired`, `InFlight`, +`Completed`, `Unreplayable`, or `Mismatched` when the returned fingerprint differs +from the one presented. Measured across all four sequential cases on the canonical +DDL, every outcome is decidable in one round trip. ## References diff --git a/docs/decisions/0039-optimistic-concurrency-token.md b/docs/decisions/0039-optimistic-concurrency-token.md index 9b39b604..43e5212d 100644 --- a/docs/decisions/0039-optimistic-concurrency-token.md +++ b/docs/decisions/0039-optimistic-concurrency-token.md @@ -179,9 +179,8 @@ routing `SoftDelete` through the same stamp-and-increment primitive. `AuditableEntity.Version` to `long`; route `SoftDelete` through the same stamp-and-increment primitive as `MarkUpdated`; declare `row_version bigint NOT NULL DEFAULT 0` on every mutable tenancy table and - configure it as the EF concurrency token with - `IsConcurrencyToken().ValueGeneratedOnAddOrUpdate()` — **not** `IsRowVersion()`, - which maps to a provider-generated `bytea`/`rowversion` this design does not use. + configure it as the EF concurrency token with **`IsConcurrencyToken()` and + nothing else** — see Amendment 1, which measured what the other two forms do. - **Phase 02a Packet 6, step 1 — the propagation this ADR is not, on its own.** Until these land the corpus answers the question twice, and an implementer reading a standard rather than this ADR gets the withdrawn answer: @@ -222,7 +221,48 @@ is part of shipping them. Naming them here is a commitment, not a description. ## Amendments -None. +### Amendment 1 — `IsConcurrencyToken()` alone; the `bytea` rationale was false (2026-08-27) + +§ Implementation Notes prescribed +`IsConcurrencyToken().ValueGeneratedOnAddOrUpdate()` and rejected `IsRowVersion()` +on the grounds that it "maps to a provider-generated `bytea`". **Both halves were +wrong**, and the prescribed form would have made this ADR's decision a no-op. + +Measured on EF Core 10 + Npgsql 10 against `postgres:18.4-alpine`, three contexts +over one table declared exactly as the canonical template declares it +(`row_version bigint NOT NULL DEFAULT 0`), each inserting a row and then setting +`Name` and `RowVersion += 1`: + +| Configuration | Property metadata | Emitted `UPDATE` | Persisted | +|---|---|---|---| +| `IsConcurrencyToken().ValueGeneratedOnAddOrUpdate()` | `vg=OnAddOrUpdate before=Ignore after=Ignore store=bigint` | `SET name = @p0` | `0` | +| `IsRowVersion()` | **identical on all five** | `SET name = @p0` | `0` | +| `IsConcurrencyToken()` | `vg=Never before=Save after=Save store=bigint` | `SET name = @p0, row_version = @p1` | `1` | + +Two corrections follow: + +- **`.ValueGeneratedOnAddOrUpdate()` is removed.** It tells EF the *database* + generates the value. Nothing here does — the column's only `DEFAULT` is `0`, + there is no trigger and no `GENERATED ALWAYS` — so EF omits the column from the + `UPDATE`, the token never leaves `0`, every `If-Match` compares equal, and a + lost update succeeds while reporting success. That is strictly worse than having + no concurrency control, because the mechanism is present and inert. +- **The `bytea` rationale is withdrawn.** On a `long` property `IsRowVersion()` + produces byte-identical metadata to the pair above and maps to store type + `bigint`. `bytea` comes from a `byte[]` CLR type, not from the API call. The + real reason to reject `IsRowVersion()` is that it is a **synonym for the broken + pairing**, not a different mapping. + +The **Decision is unchanged**: `row_version bigint`, CLR `long`, incremented in +`AuditableEntity` by the primitive that stamps the audit columns. Only the EF call +that realises it was wrong. § Implementation Notes and +[Database Standards § Concurrency](../standards/05-database.md) are corrected in +place, per the [ADR-0031 Amendment 1](0031-postgresql-major-version.md) precedent. + +`Aggregates_With_Optimistic_Concurrency_Map_RowVersion` gains a clause: no +configuration may call `ValueGeneratedOnAddOrUpdate()` or `IsRowVersion()` on a +concurrency token. A structural test can see that; it cannot see a silently +inert token. ## References diff --git a/docs/decisions/0040-ambient-unit-of-work.md b/docs/decisions/0040-ambient-unit-of-work.md index 714ab81e..ba8b8b98 100644 --- a/docs/decisions/0040-ambient-unit-of-work.md +++ b/docs/decisions/0040-ambient-unit-of-work.md @@ -319,8 +319,10 @@ a property it cannot exercise. ## Architecture Tests -None of these exist yet. All are **Phase 02a Packet 6 deliverables**, and -registering them in +None of these exist yet. The first two are **Phase 02a Packet 6 deliverables**; +the third is registered in Packet 6 and **backfilled in Phase 03**, because no +module code exists for it to scan until the second module does. Registering all +three in [21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md) is part of shipping them. @@ -334,7 +336,37 @@ is part of shipping them. ## Amendments -None. +### Amendment 1 — `SetTenantContextAsync`, and the member names (2026-08-27) + +§ Decision's interface sketch was edited after this ADR was accepted, in the +commit that propagated it into the corpus. Recording it rather than leaving the +edit silent, because an Accepted ADR whose Decision changes without a note is the +thing amendments exist to prevent: + +- **`SetTenantContextAsync(ITenantContext, CancellationToken)` was added.** The + reference body in + [31-audit-subsystem.md](../architecture/31-audit-subsystem.md) already called + it, and it belongs on the seam rather than in `TransactionBehavior`: the + statement it issues is SQL, and + [Backend Coding Standards](../standards/02-backend-coding.md) keeps SQL out of + the Application layer. The original sketch left the behavior issuing raw SQL, + which contradicted a standard this ADR cites. +- **`BeginAsync` was renamed `BeginTransactionAsync`**, matching the same + reference body. A seam with two spellings is a seam an implementer has to + choose between. + +Neither changes what the ADR decides — one connection per scope, owned by +`IUnitOfWork`, with every context and cross-cutting writer enlisted on it. + +**`app.scope` is not settable from `ITenantContext` as shipped.** The interface +(`LearnStack.SharedKernel/Tenancy/ITenantContext.cs`) carries `TenantId`, +`OrganizationId`, `UserId`, `CausalActorUserId`, `CorrelationId` and `ModuleName` +— no scope member. `SetTenantContextAsync` therefore issues `app.tenant_id` and +`app.organization_id` from it today. Whether `app.scope` becomes a context member +or arrives another way is +[Packet 7](../roadmap/phase-02a-kernel-tenancy.md)'s to decide, with +`TenantResolverMiddleware`; until then no caller sets it and the tenant-scope read +hatch is simply unused, which is the correct default. ## References diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 4ba4f395..09d9e3d7 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -50,7 +50,7 @@ Accepted ADRs are not rewritten. A new decision is a new ADR, possibly supersedi | 0036 | [Trusted Inputs for Tenant and Organization Resolution](0036-tenant-resolution-trusted-inputs.md) | Resolution by **agreement, not priority** — every authoritative signal present is resolved independently and the request proceeds only on their intersection; no request header names a tenant or an organization, one header names a **host** over an authenticated hop and LearnStack still resolves it itself; `TenantContextOrigin` caps a host-only context to the `[PublicSurface]` read set; the platform-admin override leaves the resolution model | | 0037 | [What an Idempotency Key Identifies, Owns, and Replays](0037-idempotency-key-contract.md) | A client-chosen key is a **nonce inside a tenant's key space**, not an identity: `(tenant, key)` addresses the record and a fingerprint over organization, principal, method, path, query and body decides whether replaying it answers the question asked; a fencing token owns the claim; capacity is **admission, not eviction**, so nothing unexpired is ever displaced; the guarantee is at-most-once while a claim is live and at-least-once across process death | | 0038 | [Cross-Cutting Port and Event Contracts](0038-cross-cutting-port-and-event-contracts.md) | **Supersedes ADR-0014.** Retains Dapr behind demand gates; fixes the event envelope, handler isolation, trace/audit scope and cache contracts | -| 0039 | [The Optimistic Concurrency Token](0039-optimistic-concurrency-token.md) | An explicit `row_version bigint` (CLR `long`), project-wide, incremented by `AuditableEntity.MarkUpdated`; `xmin` is rejected because the token is client-visible through ETag / `If-Match` and a dump-restore or logical-replication cutover changes it — measured, unlike the widely-cited `VACUUM FREEZE` objection, which does not | +| 0039 | [The Optimistic Concurrency Token](0039-optimistic-concurrency-token.md) | An explicit `row_version bigint` (CLR `long`), project-wide, incremented inside `AuditableEntity` by the single primitive `MarkUpdated` and `SoftDelete` both route through, and mapped with `IsConcurrencyToken()` **alone** — `ValueGeneratedOnAddOrUpdate()` / `IsRowVersion()` make EF omit the column from the `UPDATE` entirely (Amendment 1, measured); `xmin` is rejected because the token is client-visible through ETag / `If-Match` and a dump-restore or logical-replication cutover changes it — measured, unlike the widely-cited `VACUUM FREEZE` objection, which does not | | 0040 | [The Ambient Unit of Work](0040-ambient-unit-of-work.md) | One `DbConnection` per scope, owned by `IUnitOfWork`; every module `DbContext`, `IAuditStore` and `IOutbox` enlists on it, because `SET LOCAL` is connection-local and a context on its own connection reads **zero rows** under the corrected RLS policy. Spans one aggregate's write plus audit plus outbox plus any reads — cross-aggregate writes stay forbidden. Defines nesting, disposal, and the event-consumer entry point that never reaches MediatR | ## Superseded ADRs diff --git a/docs/glossary.md b/docs/glossary.md index bc11c474..1d164d45 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -289,7 +289,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **`IHostToTenantResolver`** | Interface for host → `(tenant_id, organization_id?)` resolution. Reads `platform_host_to_tenant` and **nothing else** — never the Hub, because an anonymous page load must not depend on a control plane being reachable ([ADR-0034](decisions/0034-hub-contract-surface-invariant.md)). | | **APISIX** | The gateway in standalone YAML-reload mode per [ADR-0015](decisions/0015-api-gateway-apisix.md). The intended tenant-facing ingress, demand-gated to [Phase 11](roadmap/phase-11-production-hardening.md); until then ASP.NET middleware carries the same responsibilities in-process. `/api/internal/*` is never proxied by it — that listener is mTLS-only inside the pod. | | **`OutboxProcessor`** | The BackgroundService that **claims** batches of `outbox_messages` by writing a lease (`locked_by` / `locked_until`) under `FOR UPDATE SKIP LOCKED`, dispatches each through `IEventBus`, and handles retry / dead-letter. The lease is written to the row rather than held in the transaction, because `FOR UPDATE` locks end when the transaction ends. Delivery is **at-least-once**; consumer-side `IInboxGuard` is what makes duplicates safe. See [15-event-and-outbox.md](architecture/15-event-and-outbox.md). | -| **`IInboxGuard`** | The per-module inbox-deduplication helper. Every integration-event handler must call `IsAlreadyProcessedAsync` before business logic and `MarkAsProcessed` inside the same SaveChanges. | +| **`IInboxGuard`** | The per-module inbox-deduplication helper. Every integration-event handler must call `IsAlreadyProcessedAsync` before business logic and `MarkAsProcessed` inside the same **transaction** as the business write — the ambient one the transport opens per delivery through `IUnitOfWork` ([ADR-0033](decisions/0033-audit-durability-model.md), [ADR-0040](decisions/0040-ambient-unit-of-work.md)). "The same `SaveChanges`" was the earlier formulation and ADR-0033 withdrew it. | | **`DeploymentMode`** | Enum (`Development \| SaaS \| Dedicated \| SelfHostedOnline \| SelfHostedAirGapped`) read at the composition root to select provider implementations. Modules never read this enum. | ## Hub & Licensing diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index 4d34b642..b1a20ca5 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -754,6 +754,19 @@ have to retrofit: transport swappable later ([ADR-0006](../decisions/0006-events-and-outbox.md)). +**Six of the ten have no published column list, and this packet authors them.** +Only `outbox_messages` and `idempotency_keys` have canonical DDL +([Database Standards](../standards/05-database.md)), and `tenant_locales` has a +sketch in [12-localization.md](../architecture/12-localization.md). The columns of +`tenants`, `organizations`, `tenant_domains`, `tenant_settings`, +`tenant_feature_flags`, `platform_entitlement_cache` and `platform_host_to_tenant` +are designed here, against the constraints the corpus does fix — the audit-column +set, the table classes, the composite-foreign-key rule, ADR-0017's organization +model and ADR-0008's locale model — and recorded in this packet's delivery record. +Standards 05 keeps the template and the two cross-cutting infrastructure tables; +the tenancy schema lives in its migration, so there is one source rather than two +that can disagree. + All ten tables ship with `ENABLE` **and** `FORCE ROW LEVEL SECURITY` and an explicit `WITH CHECK`. They do **not** all take the same policy, and saying they do produces a migration that cannot run: the corrected template's predicate names a `tenant_id` diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 69c748c8..cbeba4f3 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -65,10 +65,14 @@ Decisions made or referenced in this phase: ### Durable outbox dispatch -The producer side already exists: a handler calls `IOutbox.EnqueueAsync`, the row is -written in the same `SaveChanges` as the aggregate change, and the Phase 02a Packet 3 -`OutboxFlushBehavior` shell lights up here to enrol those messages on a success-`Result`. -What lands in this phase is the consumer side. +The producer side is **planned, not shipped**: `IOutbox` does not exist yet and Packet 3's +`OutboxFlushBehavior` is a pass-through shell. Both land here. A handler calls +`IOutbox.EnqueueAsync` and the row is written on the same **transaction** as the aggregate +change — the ambient one `IUnitOfWork` owns +([ADR-0040](../decisions/0040-ambient-unit-of-work.md)), not a shared `SaveChanges`, a +formulation [ADR-0033](../decisions/0033-audit-durability-model.md) withdrew — and the +`OutboxFlushBehavior` shell lights up to enrol those messages on a success-`Result`. The +table itself ships in Packet 6. The consumer side lands here too. - **`OutboxProcessor`** as a `BackgroundService` polling `outbox_messages` with `FOR UPDATE SKIP LOCKED`, dispatching each message through `IEventBus`, and marking it diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index d5ec934c..10139a53 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -148,8 +148,12 @@ CREATE INDEX ix_courses_tenant_id_organization_id ON courses (tenant_id, organiz **Every foreign key between two tenant-owned tables is composite on `tenant_id`.** -PostgreSQL evaluates referential integrity as a security-restricted operation on behalf -of the table owner, and RI checks are **not subject to Row Level Security**. A +PostgreSQL evaluates referential integrity at **runtime** as a security-restricted +operation on behalf of the table owner, and those RI triggers are **not subject to Row +Level Security**. (The DDL path is the opposite and matters in migrations: the scan +`ALTER TABLE … ADD CONSTRAINT` / `VALIDATE CONSTRAINT` performs runs as the issuing role +under its policies, so a constraint added to a populated tenant-owned table validates +against the rows that role can see. § Data Migrations carries the consequence.) A single-column `lessons.course_id → courses.id` therefore lets a row in tenant A reference a row in tenant B: the child's own `WITH CHECK` passes because its `tenant_id` is A's, and the FK check passes because it can see B's row. The result is a @@ -183,18 +187,43 @@ elsewhere would be pointing at a different tenant by definition, which the child's own `WITH CHECK` already refuses. The exception is exactly one table wide; a second one is a decision, not a convenience. -**`ON DELETE RESTRICT`, everywhere, until a phase owns deprovisioning.** A -cascade from `tenants` downward would be a tenant-deletion path nobody has -designed — see the note in § GRANT matrix that tenant hard-deprovisioning has no -owning phase. `RESTRICT` makes the absence loud. +**`ON DELETE RESTRICT` on every foreign key whose parent is an aggregate root, +until a phase owns deprovisioning.** A cascade from `tenants` or `organizations` +downward would be a tenant-deletion path nobody has designed — see the note in +§ GRANT matrix that tenant hard-deprovisioning has no owning phase. `RESTRICT` +makes the absence loud. + +The one standing exception is a **translation satellite**, which cascades from +its own parent (`ON DELETE CASCADE` in the `course_translations` fence above): a +translation is not an independent row and outliving its parent would leave a +title with nothing to title. That is deletion *within* an aggregate, not deletion +*of* one, and it is why the two fences differ. + +**The circular reference, and why it is still composite.** +`tenants.default_organization_id` points at `organizations`, which points back at +`tenants`. This direction is **not** covered by the self-keyed exception above — +that exception exists only because `tenants` has no `tenant_id` column to pair +with, and here the *child* side does: `tenants.id` **is** the tenant id, and +`organizations` already carries `UNIQUE (tenant_id, id)`. So the composite form is +expressible and is required: -**The circular reference.** `tenants.default_organization_id` points at -`organizations`, which points back at `tenants`. The column is **nullable**, and -the provisioning transaction inserts the tenant, inserts its default -organization, then `UPDATE`s the tenant — three statements in one transaction -rather than a `DEFERRABLE INITIALLY DEFERRED` constraint, because a deferred -constraint moves the failure to `COMMIT`, where the error names the constraint -and not the statement that broke it. +```sql +CONSTRAINT fk_tenants_default_organization + FOREIGN KEY (id, default_organization_id) REFERENCES organizations (tenant_id, id) + ON DELETE RESTRICT +``` + +Single-column here would be the exact hole the rule closes: referential-integrity +checks run with row security bypassed, so tenant A could commit a permanent +pointer at tenant B's organization — a row A cannot even see. Under `MATCH SIMPLE` +the check is skipped entirely while `default_organization_id` is null, which is +what makes the nullable column safe before the `UPDATE` lands. + +The column is **nullable**, and the provisioning transaction inserts the tenant, +inserts its default organization, then `UPDATE`s the tenant — three statements in +one transaction rather than a `DEFERRABLE INITIALLY DEFERRED` constraint, because +a deferred constraint moves the failure to `COMMIT`, where the error names the +constraint and not the statement that broke it. This block is the **single canonical RLS template**. [ADR-0003 Amendment 3](../decisions/0003-tenant-isolation-defense-in-depth.md), @@ -236,7 +265,32 @@ Rules: discarded before the query they are meant to protect ever runs. See [Security Standards § Tenant Context](11-security.md). - `organization_id` on a tenant-owned row is **immutable after insert**, enforced by a - `BEFORE UPDATE` trigger (`tg_
_organization_id_immutable`). A row does not move + `BEFORE UPDATE` trigger. The function is declared once and the trigger once per + org-scoped table, in the migration that creates it: + + ```sql + CREATE FUNCTION fn_organization_id_immutable() RETURNS trigger AS $$ + BEGIN + IF NEW.organization_id IS DISTINCT FROM OLD.organization_id THEN + RAISE EXCEPTION + 'organization_id is immutable after insert (table %, row %)', + TG_TABLE_NAME, OLD.id + USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER tg_courses_organization_id_immutable + BEFORE UPDATE ON courses + FOR EACH ROW EXECUTE FUNCTION fn_organization_id_immutable(); + ``` + + `IS DISTINCT FROM` rather than `<>`, so a move to or from `NULL` — tenant-wide to + org-scoped, or back — is caught too; `<>` is `NULL` when either side is null and the + trigger would pass. The restrictive `UPDATE` guard does not cover this: it admits the + row when the *new* `organization_id` is the caller's own, which is exactly the + re-parenting move. A row does not move between organizations: its audit rows ([ADR-0016](../decisions/0016-audit-log-subsystem.md)), its storage prefix `tenants/{tenant_id}/organizations/{organization_id}/…` and its cache-key prefix are @@ -350,6 +404,28 @@ tenant can ever resolve. The answer is not to drop row security — a table with give the read an explicitly declared key of its own. The resolver announces the host it is about to resolve, and the policy admits exactly that row. +`host` also carries the **normalization backstop** +[ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) assigns to this +packet. It constrains the *output* of `EffectiveHost.Normalize`, not the +algorithm: the seven-step normalization — IDN mapping, port stripping, trailing-dot +handling, IP-literal rejection — is imperative and PostgreSQL cannot evaluate it in +a `CHECK`. What the database can guarantee is that nothing un-normalized was +inserted by a path that skipped the normalizer: + +```sql +CONSTRAINT ck_platform_host_to_tenant_host_normalized CHECK ( + host = lower(host) + AND host !~ '[^a-z0-9.-]' -- ASCII only: IDN already punycoded + AND host NOT LIKE '%.' -- trailing dot already stripped + AND host NOT LIKE '%:%' -- port already stripped + AND length(host) BETWEEN 1 AND 253 +) +``` + +`EffectiveHost.Normalize` remains the **sole** normalizer; this constraint never +normalizes anything, it only refuses. A row that violates it is a bug in a writer, +not a host to be fixed up. + ```sql ALTER TABLE platform_host_to_tenant ENABLE ROW LEVEL SECURITY; ALTER TABLE platform_host_to_tenant FORCE ROW LEVEL SECURITY; @@ -432,10 +508,13 @@ closed; a fifth role requires an ADR. `:'name'` placeholders below are **psql client variables**, and the `docker-entrypoint-initdb.d` runner binds none of them: measured, the script as it stood failed on its first statement with `syntax error at or near ":"`. -`\getenv` reads each one from the container environment, which -[Infrastructure Standards § Local Infrastructure](12-infrastructure.md) already -requires to be `${VAR:-fallback}` in compose with a matching row in -`.env.example`. Measured, it works under the entrypoint's `ON_ERROR_STOP=1`, and +`\getenv` reads each one from the container environment. +[Infrastructure Standards § Local Infrastructure](12-infrastructure.md) requires +every credential to be `${VAR:-fallback}` in compose with a matching row in +`.env.example`; **the four rows and the four compose entries do not exist yet** +and land in the same Packet 6 change as the script itself, in `.env.example` and +in the `postgres` service's `environment:` block of both `dev.yml` and +`e2e.yml`. Measured, it works under the entrypoint's `ON_ERROR_STOP=1`, and an **unset** variable leaves the placeholder unbound and aborts init rather than creating a passwordless superuser-adjacent role — failing loud is the point. @@ -605,9 +684,28 @@ writing on a path that ADR deliberately keeps read-only. ## Concurrency `row_version bigint`, CLR `long`, on every entity implementing -`IOptimisticConcurrency`. Configure it with -`IsConcurrencyToken().ValueGeneratedOnAddOrUpdate()` — **not** `IsRowVersion()`, -which maps to a provider-generated `bytea` this design does not use. +`IOptimisticConcurrency`. Configure it with **`IsConcurrencyToken()` and nothing +else**. + +Neither `.ValueGeneratedOnAddOrUpdate()` nor `IsRowVersion()` may be added, and +they are the same mistake: on a `long` property they produce byte-identical +property metadata — `ValueGenerated.OnAddOrUpdate` with +`BeforeSave`/`AfterSave` = `Ignore` — and EF then **omits the column from the +`UPDATE` statement entirely**. Measured on EF Core 10 + Npgsql 10 against +PostgreSQL 18.4, on a table declared exactly as the template declares it: + +| Configuration | Emitted SQL | Persisted `row_version` | +|---|---|---| +| `IsConcurrencyToken().ValueGeneratedOnAddOrUpdate()` | `UPDATE widgets SET name = @p0` | `0` | +| `IsRowVersion()` | `UPDATE widgets SET name = @p0` | `0` | +| `IsConcurrencyToken()` | `UPDATE widgets SET name = @p0, row_version = @p1` | `1` | + +Those two forms tell EF the **database** generates the value. Nothing here does: +the column's only `DEFAULT` is `0`, there is no trigger and no +`GENERATED ALWAYS`. So the token never leaves `0`, every `If-Match` compares +equal, and optimistic concurrency silently never fires — a lost update succeeds +and reports success. `IsConcurrencyToken()` alone leaves the write behaviours at +`Save`, which is what puts the incremented value in the `SET` list. The value is incremented inside `AuditableEntity`, by the same primitive that stamps `UpdatedAt` / `UpdatedBy`, so an audited mutation is a versioned @@ -628,17 +726,20 @@ changes `xmin` while leaving `row_version` intact. - **App-side** for aggregates — `IGuidFactory.NewUuidV7()`, so a test can pin the value and the id exists before the row does. - **DB-side** `DEFAULT uuidv7()` for the high-volume append-only tables whose - rows are written by infrastructure rather than by an aggregate: - `audit_log`, `outbox_messages`, `inbox_messages`. + surrogate key is written by infrastructure rather than by an aggregate: + `audit_log` and `outbox_messages`. Both canonical fences carry the clause; + a fence and this rule disagreeing is a defect in one of them. + - `inbox_messages` is **not** one of them despite being append-only: its key + is the producing envelope's `EventId`, which the producer minted app-side. + Generating a second id there would defeat the deduplication the table is. + - `idempotency_keys` is not one either: it is addressed by `(tenant_id, key)` + and has no surrogate id at all. - **Never `gen_random_uuid()`.** It is a real function and produces a **v4** UUID — random, with none of the index locality UUIDv7 was adopted for. The built-in is `uuidv7()`; `gen_uuid_v7()` does not exist ([ADR-0031 Amendment 1](../decisions/0031-postgresql-major-version.md)). - Strongly-typed ids in code via value converters. - No surrogate `int` keys for domain entities. -- A table addressed by a natural key gets **no surrogate id at all** — - `idempotency_keys` is keyed `(tenant_id, key)` and generates nothing. ADR-0023's - DB-side list covers the append-only tables that do carry a surrogate id. - `bigint GENERATED BY DEFAULT AS IDENTITY` where a generated integer key is genuinely wanted — never `bigserial`, whose sequence needs a `GRANT USAGE` of its own that an identity column does not. @@ -729,7 +830,7 @@ Public read models consumed by other modules: ```sql CREATE TABLE outbox_messages ( - id uuid PRIMARY KEY, + id uuid PRIMARY KEY DEFAULT uuidv7(), occurred_at timestamptz NOT NULL DEFAULT now(), tenant_id uuid NOT NULL, organization_id uuid NULL, -- null = tenant-wide event; see note below @@ -849,8 +950,10 @@ CREATE TABLE idempotency_keys ( CONSTRAINT pk_idempotency_keys PRIMARY KEY (tenant_id, key), CONSTRAINT ck_idempotency_keys_state CHECK (state IN ('in_flight', 'completed', 'unreplayable')), - -- The 256 KiB replay cap becomes something the database enforces rather than - -- something every caller remembers. + -- ADR-0037's replay cap is 256 KiB "headers included", so this is a floor + -- under it rather than the cap itself — the database can bound the body + -- cheaply and the store enforces the headers-inclusive total, which is where + -- the serialized size actually lives. CONSTRAINT ck_idempotency_keys_body_size CHECK (body IS NULL OR octet_length(body) <= 262144), -- Matches [Idempotent]'s header bounds, so a key the API accepted always fits. @@ -958,8 +1061,10 @@ Forbidden: string interpolation with non-constant values. transaction, leaving a window in which one has committed and the other has not. This line previously read "Multiple `DbContext` instances within one logical transaction", which also forbade the safe shape. -- Reading a tenant-owned table outside the ambient transaction. `SET LOCAL` is +- Reading a tenant-owned table from a transaction that has not issued its own + `SET LOCAL` — the ambient one, or one of the closed set of out-of-band setters + in [ADR-0040](../decisions/0040-ambient-unit-of-work.md). `SET LOCAL` is connection- and transaction-local, so such a read returns **zero rows** under - the corrected policy — silently, because a policy that filters everything - looks exactly like a table with no matching data. + the corrected policy — silently, because a policy that filters everything looks + exactly like a table with no matching data. - Tenant tables without RLS in production migrations (CI rejects). diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index 8143b53e..feb3a97d 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -14,7 +14,7 @@ title: LearnStack Test Pyramid flowchart TB e2e[End-to-end / Playwright
handful of golden flows] contract[Contract & API tests
OpenAPI + provider fakes] - integration[Integration tests
Testcontainers Postgres / Valkey / SeaweedFS] + integration[Integration tests
Testcontainers Postgres] arch[Architecture tests
module boundaries + tenant invariants] unit[Unit tests
domain + application + UI logic] @@ -25,7 +25,7 @@ Text fallback (for renderers without Mermaid support — pyramid base → top): - **Unit tests** (base layer, widest) — domain + application + UI logic. - **Architecture tests** — module boundaries + tenant invariants. -- **Integration tests** — Testcontainers Postgres / Valkey / SeaweedFS. +- **Integration tests** — Testcontainers **Postgres**. Valkey, SeaweedFS and the rest arrive with the phase that ships something calling them ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md)). - **Contract & API tests** — OpenAPI + provider fakes. - **End-to-end / Playwright** (top, narrowest) — handful of golden flows. @@ -57,8 +57,10 @@ belongs to is decided by what it needs, not by what it is about: Docker. Everything that is a property of the API surface lives here: routing, the error shape, idempotency, limits, the tenancy edge. These run in the required `backend` CI job alongside the unit suite. -- **Data tests** — real Postgres + Valkey + SeaweedFS via Testcontainers, one - database per test class (or Respawn between tests). Everything that is a +- **Data tests** — real **Postgres** via Testcontainers, connected as + `learnstack_app`, one database per test class (or Respawn between tests). Not + Valkey and not SeaweedFS: nothing the backend runs calls either, and both sit + behind the gated compose profile ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md)). Everything that is a property of the schema lives here, and **every tenant-isolation invariant** does. The schema itself lands in Packet 6; these arrive with the **isolation suite** in Packet 7 — connected as `learnstack_app`, because a test run as the owner or as a diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index 7d5cb2d3..724af576 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -255,14 +255,18 @@ consequences follow, and both are load-bearing: ### The out-of-band setters -`TransactionBehavior` is the general case, not the only one. Four callers run where -no ambient transaction exists yet, so each owns a **short transaction of its own** -whose first statement is the `SET LOCAL`. The set is closed -([ADR-0040](../decisions/0040-ambient-unit-of-work.md)): - -| Setter | Why it cannot be `TransactionBehavior` | -|---|---| -| The integration-event transport, per delivery | There is no MediatR request: `InProcessEventBus` invokes the handler directly, so no behavior runs. It opens the transaction itself, from the delivery's `EventTenantContext` | +`TransactionBehavior` is the general case, not the only one. Six setters exist in +total and the set is closed +([ADR-0040 § Who sets `app.tenant_id`, completely](../decisions/0040-ambient-unit-of-work.md) +is the authority; it is reproduced here because this section is the placement +authority). Two set it on the **ambient** transaction; four own a **short +transaction of their own**, because they run where no ambient transaction exists +yet: + +| Setter | Transaction | Why it is not `TransactionBehavior` | +|---|---|---| +| `TransactionBehavior` | ambient | — the general case | +| The integration-event transport, per delivery | ambient — it opens it | There is no MediatR request: `InProcessEventBus` invokes the handler directly, so no behavior runs. It opens the ambient transaction itself, from the delivery's `EventTenantContext` | | `IIdempotencyStore` (durable) | A claim is taken **before** the pipeline reaches step 6 ([ADR-0037](../decisions/0037-idempotency-key-contract.md)) | | `IAuditStore.WriteStandaloneAsync` | An audit row that must survive the rollback of the operation it describes cannot share that operation's transaction ([ADR-0033](../decisions/0033-audit-durability-model.md)) | | `IAuditStore.WriteBestEffortAsync` | Same shape, SHOULD/MAY class; failures are logged and dropped | @@ -277,8 +281,12 @@ Because every `current_setting` read is called with its missing-OK argument (`tr **and** wrapped in `NULLIF(…, '')`, both an unset and a reset variable yield `NULL` and the policy predicate filters the row out. The failure mode is an empty result set, not a leak — but an empty result set arriving from production is an outage, so a -`DbCommandInterceptor` additionally asserts that `TransactionBehavior` has already issued -the `SET LOCAL` pair before any command against a `[TenantOwned]` table runs, and throws +`DbCommandInterceptor` additionally asserts that **a sanctioned setter** has already +issued the `SET LOCAL` pair on this transaction before any command against a +`[TenantOwned]` table runs — `TransactionBehavior` in the general case, or any of the +out-of-band setters above, each of which stamps the same marker on the transaction it +opens. Naming `TransactionBehavior` alone would make the guard reject every write the +idempotency store and the audit store legitimately make. It throws `TenantContextMissingException` when it has not. It cannot be a connection-checkout interceptor, for the same reason it cannot be a `DbConnectionInterceptor` that *sets* the values: checkout precedes the transaction, so the transaction-local value is not there to diff --git a/docs/standards/18-audit-coverage.md b/docs/standards/18-audit-coverage.md index c6a1b394..06f4b3e3 100644 --- a/docs/standards/18-audit-coverage.md +++ b/docs/standards/18-audit-coverage.md @@ -138,7 +138,8 @@ Rules: ## Storage - One global `audit_log` table. Phase 02a Packet 9 ships it **plain and - unpartitioned**; monthly partitioning by `occurred_at`, the retention job and the + unpartitioned**; monthly partitioning by `timestamp` — the column's name in + ADR-0033's DDL — the retention job and the lifecycle policy of [ADR-0028](../decisions/0028-audit-log-partition-management.md) arrive in [Phase 11](../roadmap/phase-11-production-hardening.md). "Monthly from day one" was the earlier plan and [ADR-0033](../decisions/0033-audit-durability-model.md) diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 8d7118c0..9035f425 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -635,9 +635,15 @@ rules that need a second `DbContext` are owed by Phase 03. #### `Aggregates_With_Optimistic_Concurrency_Map_RowVersion` - **Asserts:** every entity implementing `IOptimisticConcurrency` has its `Version` - configured as the concurrency token against a `row_version` column, and **no** - configuration calls `IsRowVersion()` — which maps to a provider-generated `bytea` - rather than the explicit counter ADR-0039 chose. + configured as the concurrency token against a `row_version` column, **and** that + the property's `ValueGenerated` is `Never` with both save behaviours at `Save`. + Neither `ValueGeneratedOnAddOrUpdate()` nor `IsRowVersion()` may appear: on a + `long` property the two produce byte-identical metadata, and EF then omits the + column from the `UPDATE` entirely, so the token stays `0` and every lost update + succeeds ([ADR-0039 Amendment 1](../decisions/0039-optimistic-concurrency-token.md), + measured). A structural test can see the metadata; it cannot see a silently + inert token, which is why the assertion is on `ValueGenerated` and not on the + call site. - **Source:** ADR-0039; [05-database.md § Concurrency](05-database.md). - **Type:** xUnit + EF model inspection. **Kind:** structural. - **Status:** **Registered.** **Phase:** 02a Packet 6. From 4fb3baa8488ddd2fbe6e18704a749de63dac2458 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 27 Aug 2026 19:34:34 +0300 Subject: [PATCH 05/35] fix(docs): close what the Sonnet round found in the Opus round's fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten agents, five lenses, every finding re-measured before acting. Eleven confirmed, and all eleven were defects in the previous fix commit rather than in the corpus it corrected — which is the useful result: the second round's job is the first round's blind spot. The blocker was in ADR-0037 Amendment 2, written yesterday to fix a narrower bug and never tested against the case that matters most. Measured, four sequential claims on one key: fresh inserted=t state=in_flight fp=FP-A token=MINE live, other inserted=f state=in_flight fp=FP-A token=HOLDER'S completed inserted=f state=completed fp=FP-A status=201 expired, reclaim inserted=f state=in_flight fp=FP-B token=MINE Rows 2 and 4 are identical on `inserted` and `state`, so the decision rule the amendment stated — "the returned state and fingerprint decide the rest" — cannot tell a blocked claim from a reclaimed one. The deciding column is `claim_token`: equal to the one this call minted means this caller owns it, by insert or by reclaim. That is the same ownership-by-identity test InMemoryIdempotencyStore already performs with ReferenceEquals, and it is why TryClaimAsync takes no caller-supplied token — only the store knows the value. The normalization CHECK was looser than the normalizer it backstops. Measured: it accepted `.example.com`, `a..b.com` and `-example.com`, none of which EffectiveHost.Normalize's IsLdh gate can produce — it rejects empty labels and labels starting or ending with a hyphen. Rewritten as the LDH rule stated positively rather than as a list of prohibitions, because the prohibitions kept missing cases. Now 14/14 on the full matrix, punycode and single-label hosts included. That rewrite then broke CI, which is its own lesson: the meta job's link audit greps raw Markdown for `](`, fenced code included, so a regex containing `[a-z0-9](` fails the build with a broken link named `[a-z0-9-]*[a-z0-9]`. The pattern uses `[a-z0-9]+(` instead, and says why beside itself. Six more, each a half-finished edit of mine: - ADR-0003 got a note saying the table-class enumeration was removed. It was not removed. It is now. - Standards 05 still prescribed `SET LOCAL app.resolving_host = @host` — the exact form the previous commit's own message proved is a syntax error. Fixed in the architecture doc, missed in the standard that owns the mechanism. - The Standards 11 setter table gained a Transaction column on its header and first two rows only, leaving four rows a cell short and their transaction type missing entirely. - ADR-0023's amendment was appended after § References as a second `## Amendment 1`, in a document whose `## Amendments` container already held three. It is Amendment 4, inside the container. - The roadmap said six tables lack a column list and then named seven — and two of those seven have complete CREATE TABLE blocks in 21-feature-flags.md that IFeatureFlags already reads by column name. Four genuinely lack one; the migration transcribes the two that have one rather than re-deriving a conflicting shape. - Nothing in the corpus said how a closed-set status column is stored, leaving `organizations.status` and tenant_domains' four verification states to be guessed per table. Stated once: text + CHECK, not a PostgreSQL enum type. 736 tests green, 0 warnings under CI=true, format clean, CI-equivalent link audit clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../0003-tenant-isolation-defense-in-depth.md | 2 +- ...0023-strongly-typed-id-source-generator.md | 26 +++++++-------- .../0037-idempotency-key-contract.md | 29 ++++++++++++++--- docs/roadmap/phase-02a-kernel-tenancy.md | 16 ++++++---- docs/standards/05-database.md | 32 +++++++++++++++---- docs/standards/11-security.md | 8 ++--- 6 files changed, 76 insertions(+), 37 deletions(-) diff --git a/docs/decisions/0003-tenant-isolation-defense-in-depth.md b/docs/decisions/0003-tenant-isolation-defense-in-depth.md index d627a79e..c8790ac0 100644 --- a/docs/decisions/0003-tenant-isolation-defense-in-depth.md +++ b/docs/decisions/0003-tenant-isolation-defense-in-depth.md @@ -178,7 +178,7 @@ both are enumerated rather than left to judgement: | Class | Rule | Tables | |---|---|---| -| Tenant-owned | The corrected template | every domain table; of the Phase 02a tenancy set: `organizations`, `tenant_domains`, `tenant_locales`, `tenant_settings`, `tenant_feature_flags`, `platform_entitlement_cache`, `outbox_messages` | +| Tenant-owned | The corrected template, with the organization term present only on tables that carry an `organization_id` | every domain table, plus the Phase 02a tenancy set — enumerated and kept current in [Database Standards § Table classes](../standards/05-database.md), which splits it into org-scoped and tenant-wide | | Tenant-owned, self-keyed | The corrected template with the tenant term keyed on `id`, because the primary key *is* the tenant id | `tenants` | | Platform-scoped | `ENABLE` + `FORCE`, role-qualified per-command policies; the read is widened by an explicitly declared non-tenant predicate, writes stay tenant-keyed | `platform_host_to_tenant` | diff --git a/docs/decisions/0023-strongly-typed-id-source-generator.md b/docs/decisions/0023-strongly-typed-id-source-generator.md index bec1ca35..037ff837 100644 --- a/docs/decisions/0023-strongly-typed-id-source-generator.md +++ b/docs/decisions/0023-strongly-typed-id-source-generator.md @@ -341,20 +341,7 @@ Both rules are written in This is a clarification; the Decision is unchanged. -## References - -- [Standards 02 § Strongly-Typed Identifiers](../standards/02-backend-coding.md) -- [ADR-0031 PostgreSQL — Start on 18.x](0031-postgresql-major-version.md) — native - UUIDv7 widens the design space; this ADR commits to Vogen-emitted wrappers on - that primitive. -- [ADR-0032 Exception Handling, Logging, and Observability](0032-exception-handling-logging-and-observability.md) - — establishes the `backend/analyzers/` Roslyn analyzer location; future ID-shape - analyzers live alongside. -- [Vogen on GitHub](https://github.com/SteveDunn/Vogen) — upstream project (MIT). - ---- - -## Amendment 1 — `idempotency_keys` mints no id (2026-08-27) +### Amendment 4 — `idempotency_keys` mints no id (2026-08-27) § Implementation Notes listed `idempotency_keys` among the tables whose ids are minted DB-side with `uuidv7()`. It has no id to mint. The shipped port @@ -371,3 +358,14 @@ in scope for either path. The list is corrected in place, per the precedent in [ADR-0031 Amendment 1](0031-postgresql-major-version.md). [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) creates the table. + +## References + +- [Standards 02 § Strongly-Typed Identifiers](../standards/02-backend-coding.md) +- [ADR-0031 PostgreSQL — Start on 18.x](0031-postgresql-major-version.md) — native + UUIDv7 widens the design space; this ADR commits to Vogen-emitted wrappers on + that primitive. +- [ADR-0032 Exception Handling, Logging, and Observability](0032-exception-handling-logging-and-observability.md) + — establishes the `backend/analyzers/` Roslyn analyzer location; future ID-shape + analyzers live alongside. +- [Vogen on GitHub](https://github.com/SteveDunn/Vogen) — upstream project (MIT). diff --git a/docs/decisions/0037-idempotency-key-contract.md b/docs/decisions/0037-idempotency-key-contract.md index 77e91083..9666a533 100644 --- a/docs/decisions/0037-idempotency-key-contract.md +++ b/docs/decisions/0037-idempotency-key-contract.md @@ -477,11 +477,30 @@ RETURNING (xmax = 0) AS inserted, state, fingerprint, claim_token, status_code, content_type, headers, body; ``` -`xmax = 0` distinguishes a fresh insert from a conflict resolution, and the -returned `state` and `fingerprint` decide the rest: `Acquired`, `InFlight`, -`Completed`, `Unreplayable`, or `Mismatched` when the returned fingerprint differs -from the one presented. Measured across all four sequential cases on the canonical -DDL, every outcome is decidable in one round trip. +**The deciding column is `claim_token`, not `state`.** `xmax = 0` separates a fresh +insert from a conflict resolution, but `state` and `fingerprint` alone cannot +separate the two conflict outcomes that matter most: a claim blocked by a live lease +and a claim that *reclaimed* an expired one both return `state = 'in_flight'` with +the same stored fingerprint. Measured — the only difference is whose token came +back: + +| Case | `inserted` | `state` | `claim_token` returned | Outcome | +|---|---|---|---|---| +| No row | `t` | `in_flight` | **this call's** | `Acquired` | +| Live lease held by another | `f` | `in_flight` | the **holder's** | `InFlight` | +| Expired lease, reclaimed | `f` | `in_flight` | **this call's** | `Acquired` | +| Completed, unexpired | `f` | `completed` | the completer's | `Completed` (replay the four response columns) | +| Completed with no response | `f` | `unreplayable` | the completer's | `Unreplayable` | +| Any of the above, different fingerprint | — | — | — | `Mismatched`, which wins over all of them | + +So the store compares the `claim_token` the statement returned against the one it +generated for this call: **equal means this caller owns the claim** — whether by +insert or by reclaim — and anything else means someone else does. That is the same +ownership-by-identity test `InMemoryIdempotencyStore` already performs with +`ReferenceEquals`; the durable store performs it with a token because it has no +object to compare. `TryClaimAsync` takes no caller-supplied token precisely so this +comparison cannot be skipped at a call site: only the store knows the value it +minted. ## References diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index b1a20ca5..16ad5d54 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -754,12 +754,16 @@ have to retrofit: transport swappable later ([ADR-0006](../decisions/0006-events-and-outbox.md)). -**Six of the ten have no published column list, and this packet authors them.** -Only `outbox_messages` and `idempotency_keys` have canonical DDL -([Database Standards](../standards/05-database.md)), and `tenant_locales` has a -sketch in [12-localization.md](../architecture/12-localization.md). The columns of -`tenants`, `organizations`, `tenant_domains`, `tenant_settings`, -`tenant_feature_flags`, `platform_entitlement_cache` and `platform_host_to_tenant` +**Four of the ten have no published column list at all, and this packet authors +them.** `outbox_messages` and `idempotency_keys` have canonical DDL +([Database Standards](../standards/05-database.md)); `tenant_feature_flags` and +`platform_entitlement_cache` have `CREATE TABLE` blocks in +[21-feature-flags.md](../architecture/21-feature-flags.md) that `IFeatureFlags`'s +resolution logic already reads by column name, so the migration **transcribes** +those and adds the row-security clauses that architecture doc omits; +`tenant_locales` has a sketch in +[12-localization.md](../architecture/12-localization.md). The columns of `tenants`, +`organizations`, `tenant_domains`, `tenant_settings` and `platform_host_to_tenant` are designed here, against the constraints the corpus does fix — the audit-column set, the table classes, the composite-foreign-key rule, ADR-0017's organization model and ADR-0008's locale model — and recorded in this packet's delivery record. diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 10139a53..7d50dc49 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -414,11 +414,18 @@ inserted by a path that skipped the normalizer: ```sql CONSTRAINT ck_platform_host_to_tenant_host_normalized CHECK ( - host = lower(host) - AND host !~ '[^a-z0-9.-]' -- ASCII only: IDN already punycoded - AND host NOT LIKE '%.' -- trailing dot already stripped - AND host NOT LIKE '%:%' -- port already stripped - AND length(host) BETWEEN 1 AND 253 + -- The LDH rule, stated positively: every label starts and ends alphanumeric + -- and may carry hyphens between, labels joined by single dots. Written this + -- way rather than as a list of prohibitions, because the prohibitions kept + -- missing cases: measured, a `!~ '[^a-z0-9.-]'` form accepted + -- `.example.com`, `a..b.com` and `-example.com`, none of which + -- EffectiveHost.Normalize's own IsLdh gate can produce. Lowercase, no + -- trailing dot and no embedded port all fall out of the pattern. + -- `[a-z0-9]+(` rather than `[a-z0-9](`: the second spells `](`, which the CI + -- link audit greps for as a Markdown link — it does not skip fenced code — + -- and then fails the meta job on a target named `[a-z0-9-]*[a-z0-9]`. + host ~ '^[a-z0-9]+([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]+([a-z0-9-]*[a-z0-9])?)*$' + AND length(host) <= 253 ) ``` @@ -469,8 +476,13 @@ raises `new row violates row-level security policy`. a transaction block emits `WARNING: SET LOCAL can only be used in transaction blocks` and has no effect, and a session-level `set_config(…, false)` would survive on a pooled connection into the next request. `CachedHostToTenantResolver` therefore opens a short -read-only transaction on a cache miss, issues `SET LOCAL app.resolving_host = @host`, -runs the single-row `SELECT`, and commits. The failure mode of forgetting it is an empty +read-only transaction on a cache miss, issues +`SELECT set_config('app.resolving_host', @host, true)`, runs the single-row `SELECT`, +and commits. It must be the **function** form: `SET` takes no bind parameter — +`SET LOCAL app.resolving_host = $1` is a syntax error, measured — and interpolating +the host into a `SET` on the anonymous page-load path would be an injection site. +`set_config`'s third argument `true` is what makes it transaction-local, exactly as +`SET LOCAL` is. The failure mode of forgetting it is an empty result and a 404 — never a wider read. Because these policies are role-qualified `TO learnstack_app`, **no policy applies to @@ -782,6 +794,12 @@ changes `xmin` while leaving `row_version` intact. constraint so they cannot be declared alongside the columns they guard, and they solve the same half of the problem `NULLS NOT DISTINCT` solves while leaving the cross-tier collision open. +- **A closed-set status column is `text NOT NULL` with a `CHECK (col IN (…))`** — not + a PostgreSQL `enum` type and not an `int`. An `enum` type's values can only be added, + never removed or reordered, and every change is a migration on the type rather than + on the table; an `int` makes a dump unreadable and a mistyped value + indistinguishable from a valid one. `idempotency_keys.state` is the worked example. + The CLR side stays a C# `enum` and maps through a value converter. - Foreign keys with `ON DELETE` set explicitly. ## Soft Delete diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index 724af576..8ea05fb7 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -267,10 +267,10 @@ yet: |---|---|---| | `TransactionBehavior` | ambient | — the general case | | The integration-event transport, per delivery | ambient — it opens it | There is no MediatR request: `InProcessEventBus` invokes the handler directly, so no behavior runs. It opens the ambient transaction itself, from the delivery's `EventTenantContext` | -| `IIdempotencyStore` (durable) | A claim is taken **before** the pipeline reaches step 6 ([ADR-0037](../decisions/0037-idempotency-key-contract.md)) | -| `IAuditStore.WriteStandaloneAsync` | An audit row that must survive the rollback of the operation it describes cannot share that operation's transaction ([ADR-0033](../decisions/0033-audit-durability-model.md)) | -| `IAuditStore.WriteBestEffortAsync` | Same shape, SHOULD/MAY class; failures are logged and dropped | -| The `AuditConfig` override loader | An out-of-band cached projection, never a request-path query | +| `IIdempotencyStore` (durable) | its own short one | A claim is taken **before** the pipeline reaches step 6 ([ADR-0037](../decisions/0037-idempotency-key-contract.md)) | +| `IAuditStore.WriteStandaloneAsync` | its own short one | An audit row that must survive the rollback of the operation it describes cannot share that operation's transaction ([ADR-0033](../decisions/0033-audit-durability-model.md)) | +| `IAuditStore.WriteBestEffortAsync` | its own short one | Same shape, SHOULD/MAY class; failures are logged and dropped | +| The `AuditConfig` override loader | its own short read | An out-of-band cached projection, never a request-path query | Every one of them connects as `learnstack_app`. A setter that reached for `learnstack_platform` would be invisible to the isolation suite, which is the From f19728d0e91e9bc3b0823702347dd5ac04529adb Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 27 Aug 2026 19:40:55 +0300 Subject: [PATCH 06/35] feat(kernel): the concurrency token and the tenancy identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packet 6 step 2. The kernel and the schema disagreed in three places, and the first migration cannot be written until they do not. **The token is `long`, and one primitive advances it.** IOptimisticConcurrency and AuditableEntity carried `uint` — the Npgsql convention for an xmin token, which ADR-0039 rejected — against a `row_version bigint` column and a shipped `EntityTag.For(long)` surface. Both are `long` now. MarkUpdated and SoftDelete both route through a private Touch(at, by) that stamps UpdatedAt/UpdatedBy and increments. SoftDelete used to assign the two fields itself, so an increment placed in MarkUpdated alone would have left a soft delete un-versioned and a client's pre-delete ETag would have kept satisfying If-Match on the row it deleted. Mutation-checked both ways: reverting SoftDelete fails SoftDelete_Advances_The_Row_Version and only that case; dropping the increment fails two. **The template's audit columns could not be satisfied.** `updated_by uuid NOT NULL` against a nullable UpdatedBy that MarkCreated never stamps would have rejected every INSERT; both are NULL now, with `coalesce(updated_at, created_at)` named for last-touched. And `deleted_at` / `deleted_by` were listed as a soft-delete opt-in while AuditableEntity implements ISoftDelete unconditionally — EF maps them on every derived table, so a table that omitted them could not materialize its own entity. They are unconditional; what is opt-in is the query filter. **TenantId and OrganizationId** in SharedKernel, per ADR-0023 Amendment 2's cross-cutting placement: both appear on ITenantContext, on every marked entity, in cache keys, job payloads and envelopes, so a module-owned type would make each of those a reference to Tenancy. Neither has a New() — a tenant id is assigned by the registry that owns the Tenant aggregate, because a handler that minted its own could not satisfy the self-keyed policy's WITH CHECK. I caught one of my own tests agreeing with the code rather than constraining it, which is this packet's recurring lesson and worth recording. The first version asserted the "canonical conversion mask" by round-tripping through STJ and the TypeConverter. Measured: with the mask removed from TenantId entirely, all seven cases still passed — Vogen's DEFAULT Conversions already emits both. What the mask adds beyond the default is EfCoreValueConverter, so that is what the assertion is on now, and the mutant dies. The platform tenant sentinel is deliberately NOT added. The corpus asks for one and fixes its value nowhere; the only consumer is audit_log, which Packet 9 owns. Choosing a one-way-door identifier for a table that does not exist is not this step's call, and the absence is documented on the type rather than left to be rediscovered. 737 tests green, 0 warnings under CI=true, format clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../Domain/AuditableEntity.cs | 37 +++++- .../Identifiers/OrganizationId.cs | 36 ++++++ .../Identifiers/TenantId.cs | 44 +++++++ .../Persistence/IOptimisticConcurrency.cs | 2 +- .../Domain/AuditableEntityTests.cs | 57 +++++++++ .../Identifiers/TenancyIdentifierTests.cs | 108 ++++++++++++++++++ docs/standards/05-database.md | 34 ++++-- .../21-architecture-tests-catalogue.md | 6 +- 8 files changed, 309 insertions(+), 15 deletions(-) create mode 100644 backend/src/LearnStack.SharedKernel/Identifiers/OrganizationId.cs create mode 100644 backend/src/LearnStack.SharedKernel/Identifiers/TenantId.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/TenancyIdentifierTests.cs diff --git a/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs b/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs index 7e1aefd3..72cb4253 100644 --- a/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs +++ b/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs @@ -40,7 +40,16 @@ protected AuditableEntity() public UserId? DeletedBy { get; protected set; } - public uint Version { get; protected set; } + /// + /// The optimistic-concurrency token, mapped to row_version bigint + /// (ADR-0039). + /// + /// + /// Advanced by , which every update path routes through, + /// so an audited mutation is a versioned mutation. It starts at 0 and + /// the column's DEFAULT 0 agrees, so an insert needs no special case. + /// + public long Version { get; protected set; } /// /// Convenience projection of for in-process @@ -81,9 +90,7 @@ public void MarkCreated(DateTimeOffset at, UserId by) public void MarkUpdated(DateTimeOffset at, UserId by) { EnsureValidAuditInput(at, by); - - UpdatedAt = at; - UpdatedBy = by; + Touch(at, by); } /// @@ -100,8 +107,30 @@ public void SoftDelete(DateTimeOffset at, UserId by) DeletedAt = at; DeletedBy = by; + Touch(at, by); + } + + /// + /// The one update primitive: stamps / + /// and advances . + /// + /// + /// + /// Every path that stamps an update goes through here, and that is the + /// whole point rather than tidiness. used to assign + /// the two fields itself; with the version counter living in + /// alone, a soft delete would have left the token + /// where it was, and a client holding the pre-delete ETag would still satisfy + /// If-Match on the row it had just deleted. The guarantee ADR-0039 + /// wants — an audited mutation is a versioned mutation — is a property of + /// this method existing, not of the two callers remembering. + /// + /// + private void Touch(DateTimeOffset at, UserId by) + { UpdatedAt = at; UpdatedBy = by; + Version++; } // Audit metadata must always be meaningful: the default timestamp diff --git a/backend/src/LearnStack.SharedKernel/Identifiers/OrganizationId.cs b/backend/src/LearnStack.SharedKernel/Identifiers/OrganizationId.cs new file mode 100644 index 00000000..14871cf8 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Identifiers/OrganizationId.cs @@ -0,0 +1,36 @@ +using Vogen; + +namespace LearnStack.SharedKernel.Identifiers; + +/// +/// A sub-unit within a tenant, per +/// ADR-0017. +/// +/// +/// +/// Cross-cutting for the same reason is: it rides on +/// ITenantContext, on every [OrganizationScoped] entity, in +/// organization-scoped cache keys, in job payloads and on +/// IntegrationEventEnvelope. Identity and every other module hold it **by +/// value** and read organization data through an application contract; the +/// Organization aggregate itself belongs to +/// LearnStack.Modules.Tenancy.Domain and nowhere else +/// (ADR-0017 Amendment 2). +/// +/// +/// Nullable at almost every use site, and the null means something. A +/// tenant-owned row with no organization is tenant-wide — visible to every +/// organization in its tenant — which is why the canonical policy's organization +/// term reads organization_id IS NULL OR organization_id = … rather than an +/// equality alone. It is not "unknown"; it is a scope. +/// +/// +/// Values are minted through the injected IGuidFactory +/// (OrganizationId.From(guidFactory.NewUuidV7())) so a test can pin them, +/// per Standards 02 § Time. Unlike there is no external +/// registry: an organization is created by the tenant that owns it, inside a +/// transaction that has already set app.tenant_id. +/// +/// +[ValueObject(LearnStackVogenDefaults.IdMask)] +public readonly partial record struct OrganizationId : IStronglyTypedId; diff --git a/backend/src/LearnStack.SharedKernel/Identifiers/TenantId.cs b/backend/src/LearnStack.SharedKernel/Identifiers/TenantId.cs new file mode 100644 index 00000000..0c055dc5 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Identifiers/TenantId.cs @@ -0,0 +1,44 @@ +using Vogen; + +namespace LearnStack.SharedKernel.Identifiers; + +/// +/// The tenant a row belongs to — the identifier every isolation layer keys on. +/// +/// +/// +/// Lives in rather than in the Tenancy +/// module, per ADR-0023 Amendment 2's cross-cutting placement rule: it appears in +/// ITenantContext, on every [TenantOwned] entity, in cache keys, in +/// job payloads and in integration-event envelopes, so a module-owned type would +/// make every one of those a reference to Tenancy. +/// +/// +/// There is no TenantId.New(), and that is a constraint rather than an +/// omission. A tenant id is never minted inside a handler: the registry that +/// owns the Tenant aggregate assigns it — the Hub in SaaS / Dedicated, +/// configuration in Self-Hosted, the fixture in a seed — and the provisioning +/// transaction sets app.tenant_id to that value before the INSERT, +/// so the self-keyed policy's WITH CHECK passes. A handler that generated +/// its own could not satisfy its own policy. See +/// Database Standards +/// § Table classes. +/// +/// +/// The platform sentinel is deliberately absent. The corpus refers to a +/// "sentinel platform tenant id" for the one row that has no tenant of its own — +/// the read-sensitive audit row written inside +/// EnterPlatformAdminScope, which describes a cross-tenant operation. Its +/// *value* is fixed nowhere, and the only thing that consumes it is +/// audit_log, which +/// Phase 02a +/// Packet 9 owns. Choosing the value here would fix a one-way-door +/// identifier for a table that does not exist yet; Packet 9 chooses it, with the +/// schema that stores it. Note that ADR-0036 forbids the sentinel on a different +/// path — an unauthenticated tenant-assertion rejection must never write under it +/// — and the two rules do not conflict: one is an audited operator action, the +/// other an anonymous request. +/// +/// +[ValueObject(LearnStackVogenDefaults.IdMask)] +public readonly partial record struct TenantId : IStronglyTypedId; diff --git a/backend/src/LearnStack.SharedKernel/Persistence/IOptimisticConcurrency.cs b/backend/src/LearnStack.SharedKernel/Persistence/IOptimisticConcurrency.cs index 53f10a5c..02089a43 100644 --- a/backend/src/LearnStack.SharedKernel/Persistence/IOptimisticConcurrency.cs +++ b/backend/src/LearnStack.SharedKernel/Persistence/IOptimisticConcurrency.cs @@ -27,5 +27,5 @@ public interface IOptimisticConcurrency /// update succeeds. /// /// - uint Version { get; } + long Version { get; } } diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/AuditableEntityTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/AuditableEntityTests.cs index 7a476995..7e4a9d7a 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/AuditableEntityTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/AuditableEntityTests.cs @@ -69,6 +69,63 @@ public void SoftDelete_SetsDeletedColumns_AndAlsoBumpsUpdated() aggregate.UpdatedBy.Should().Be(Actor); } + // ---- the concurrency token (ADR-0039) -------------------------------- + + [Fact] + public void MarkCreated_LeavesTheVersionAtZero() + { + // The column's DEFAULT 0 and the CLR default have to agree, or an insert + // needs a special case that nothing would remember to write. + var aggregate = new TestAuditableAggregate(TestId.New()); + + aggregate.MarkCreated(T0, Actor); + + aggregate.Version.Should().Be(0); + } + + [Fact] + public void MarkUpdated_AdvancesTheRowVersion() + { + var aggregate = new TestAuditableAggregate(TestId.New()); + aggregate.MarkCreated(T0, Actor); + + aggregate.MarkUpdated(T0.AddHours(1), Actor); + aggregate.MarkUpdated(T0.AddHours(2), Actor); + + aggregate.Version.Should().Be(2, "every audited mutation is a versioned mutation"); + } + + [Fact] + public void SoftDelete_Advances_The_Row_Version() + { + // The case that fails when the increment lives in MarkUpdated alone. + // SoftDelete stamps UpdatedAt/UpdatedBy itself, so a delete would leave + // the token where it was — and a client holding the pre-delete ETag would + // still satisfy If-Match on the row it had just deleted. Route both paths + // through one primitive and this cannot happen; delete the routing and + // this test is what notices. + var aggregate = new TestAuditableAggregate(TestId.New()); + aggregate.MarkCreated(T0, Actor); + aggregate.MarkUpdated(T0.AddHours(1), Actor); + var beforeDelete = aggregate.Version; + + aggregate.SoftDelete(T0.AddDays(3), Actor); + + aggregate.Version.Should().BeGreaterThan(beforeDelete); + } + + [Fact] + public void TheVersionIsWideEnoughForTheColumnItMapsTo() + { + // row_version is bigint, so the CLR side is long. It was uint — the + // Npgsql convention for an xmin token, which ADR-0039 rejected — and a + // uint property against a bigint column round-trips wrong at the top of + // the range rather than failing loudly. + typeof(IOptimisticConcurrency) + .GetProperty(nameof(IOptimisticConcurrency.Version))! + .PropertyType.Should().Be(); + } + [Fact] public void ISoftDelete_DeletedBy_IsStronglyTypedUserId() { diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/TenancyIdentifierTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/TenancyIdentifierTests.cs new file mode 100644 index 00000000..e65e9dc4 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/TenancyIdentifierTests.cs @@ -0,0 +1,108 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text.Json; +using FluentAssertions; +using LearnStack.SharedKernel.Identifiers; +using Xunit; + +namespace LearnStack.Tests.Unit.SharedKernel.Identifiers; + +/// +/// The two kernel-level tenancy identifiers Packet 6 introduces. +/// +/// +/// +/// proves the emitter pipeline works against a +/// synthetic id. These cases exist because TenantId and +/// OrganizationId are the two the isolation layers key on: the cache-key +/// segment, the RLS session variable, the query filter and the envelope all +/// stringify them, and every one of those breaks differently if the conversions +/// are not on. A synthetic id passing does not prove these two do — the mask is +/// per-declaration, and a declaration that forgot it compiles. +/// +/// +public sealed class TenancyIdentifierTests +{ + private static readonly Guid Sample = Guid.Parse("019712ac-1234-7000-8000-0000000000ab"); + + [Theory] + [InlineData(typeof(TenantId))] + [InlineData(typeof(OrganizationId))] + public void CarriesTheEfCoreHalfOfTheCanonicalMask(Type idType) + { + // THE assertion, and the only one here that constrains the declaration. + // Vogen's DEFAULT Conversions already emits the System.Text.Json converter + // and the TypeConverter — measured: a round-trip test over both passes with + // the mask removed, so it agrees with the code rather than constraining it. + // What LearnStackVogenDefaults.IdMask adds beyond the default is + // EfCoreValueConverter (and its comparer), and without it every entity + // configuration mapping this id has to hand-roll a converter or fail at + // model build. Delete the mask and this test is what notices. + idType.GetNestedTypes().Select(t => t.Name) + .Should().Contain(["EfCoreValueConverter", "EfCoreValueComparer"]); + } + + [Theory] + [InlineData(typeof(TenantId))] + [InlineData(typeof(OrganizationId))] + public void RoundTripsThroughTheTwoWireFormatsItIsCarriedIn(Type idType) + { + // Not a mask assertion — see above, these come from Vogen's default — but + // the two paths the id actually travels: STJ for the envelope and every + // API payload, TypeConverter for route binding and IConfiguration. Worth + // pinning because a future declaration could narrow the mask rather than + // drop it. + var id = Activator.CreateInstance(idType); + id = idType.GetMethod("From", [typeof(Guid)])!.Invoke(null, [Sample]); + + var json = JsonSerializer.Serialize(id, idType); + JsonSerializer.Deserialize(json, idType).Should().Be(id); + + TypeDescriptor.GetConverter(idType) + .ConvertFromString(null, CultureInfo.InvariantCulture, Sample.ToString()) + .Should().Be(id); + } + + [Fact] + public void TheTwoIdentifiersAreNotInterchangeable() + { + // The entire reason they are types rather than Guids. A handler that + // passed an organization where a tenant belongs is the bug the isolation + // layers cannot catch, because both are uuid at the database. + typeof(TenantId).Should().NotBe(); + + TenantId.From(Sample).Value.Should().Be(OrganizationId.From(Sample).Value, + "they wrap the same primitive — which is exactly why the wrapper has to differ"); + } + + [Theory] + [InlineData(typeof(TenantId))] + [InlineData(typeof(OrganizationId))] + public void AnUninitializedIdIsReportedAsSuch(Type idType) + { + // IStronglyTypedId.IsInitialized() is the only safe way to ask: Vogen's + // generated Equals returns false when either side is uninitialized, so + // `id.Equals(default)` answers false FOR a transient id and the guard it + // protects never runs. AuditableEntity.EnsureValidAuditInput depends on + // this being right. + var uninitialized = (IStronglyTypedId)Activator.CreateInstance(idType)!; + + uninitialized.IsInitialized().Should().BeFalse(); + } + + [Theory] + [InlineData(typeof(TenantId))] + [InlineData(typeof(OrganizationId))] + public void TheseIdentifiersMintNothingOnTheirOwn(Type idType) + { + // No New() / NewId() factory, deliberately. A tenant id is assigned by the + // registry that owns the Tenant aggregate — a handler that generated one + // could not satisfy the self-keyed policy's WITH CHECK. An organization id + // comes from the injected IGuidFactory so a test can pin it (Standards 02 + // § Time). Either way, `Guid.NewGuid()` inside an aggregate is the thing + // this absence prevents. + idType.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static) + .Select(m => m.Name) + .Should().NotContain(["New", "NewId", "Create"]); + } +} diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 7d50dc49..8ebcff41 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -74,8 +74,17 @@ CREATE TABLE courses ( -- ... domain columns ... created_at timestamptz NOT NULL DEFAULT now(), created_by uuid NOT NULL, - updated_at timestamptz NOT NULL DEFAULT now(), - updated_by uuid NOT NULL, + -- NULL until the first update. MarkCreated stamps only created_*; a row that + -- has never been changed has no updater, and NOT NULL here would fail every + -- INSERT. Order by `coalesce(updated_at, created_at)` when you want + -- last-touched. + updated_at timestamptz NULL, + updated_by uuid NULL, + -- Unconditional, not opt-in: AuditableEntity implements ISoftDelete for + -- every aggregate, so EF maps these on every table that derives from it. A + -- table that omitted them would fail to materialize its own entity. + deleted_at timestamptz NULL, + deleted_by uuid NULL, row_version bigint NOT NULL DEFAULT 0, CONSTRAINT ux_courses_tenant_id_slug_key UNIQUE (tenant_id, slug_key), -- Composite unique on (tenant_id, id) exists solely so child tables can @@ -677,14 +686,23 @@ Mutable tenant-owned aggregates include: - `created_at timestamptz NOT NULL` - `created_by uuid NOT NULL` -- `updated_at timestamptz NOT NULL` -- `updated_by uuid NOT NULL` - -Soft-deletable aggregates also include: - +- `updated_at timestamptz NULL` — null until the first update +- `updated_by uuid NULL` - `deleted_at timestamptz NULL` - `deleted_by uuid NULL` +All six, on every table whose entity derives from `AuditableEntity`. The +`deleted_*` pair used to be listed as a soft-delete opt-in and is not one: +`AuditableEntity` implements `ISoftDelete` unconditionally, so EF maps both +columns on every such table whether the aggregate is ever soft-deleted or not. +What is opt-in is the **query filter** — see § Soft Delete. + +The `updated_*` pair used to be `NOT NULL`, which no insert could satisfy: +`MarkCreated` stamps `created_*` only, so a freshly created row has no updater and +the constraint would reject it. A row that has never been changed genuinely has +none; order by `coalesce(updated_at, created_at)` where "last touched" is +wanted. + These are populated by `AuditableEntity.MarkCreated` / `MarkUpdated` / `SoftDelete`, which aggregate methods call with the `IClock` they already inject. **Not by an EF interceptor.** The only sanctioned `SaveChanges` @@ -804,7 +822,7 @@ changes `xmin` while leaving `row_version` intact. ## Soft Delete -- Opt-in per aggregate; not a global default. +- The **columns** are not opt-in — `AuditableEntity` carries `DeletedAt` / `DeletedBy` for every aggregate, so every such table has them (§ Audit Columns). What is opt-in is whether an aggregate is ever soft-deleted and whether its query filter excludes deleted rows. - Soft-deleted rows excluded via global EF query filter where applicable. - Scheduled purge job removes rows past retention. diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 9035f425..4f677154 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -656,8 +656,10 @@ rules that need a second `DbContext` are owed by Phase 03. leave a soft delete un-versioned and a client's pre-delete ETag would keep satisfying `If-Match` on the row it deleted. - **Source:** ADR-0039 § Why `MarkUpdated` and not an interceptor. -- **Type:** xUnit. **Kind:** behavioural. -- **Status:** **Registered.** **Phase:** 02a Packet 6. +- **Type:** xUnit (`LearnStack.Tests.Unit`, `AuditableEntityTests`). **Kind:** behavioural. +- **Status:** **Implemented** (Packet 6 step 2). Mutation-checked: routing + `SoftDelete` back to stamping the fields itself fails this case and only this + case. #### `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork` From 6ca7534e66d6abe3a9f87aa2aa86f13e494baaf0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 27 Aug 2026 20:06:13 +0300 Subject: [PATCH 07/35] fix(kernel): close the Step 2 Opus round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten agents, five lenses, every finding re-measured here. Twenty confirmed, no blockers — the schema-facing work held. What did not was a third test of mine that agreed with the code instead of constraining it, and two withdrawn rules that survived in documents step 2 did not touch. **The token-width test guarded the wrong member.** It asserted `IOptimisticConcurrency.Version` is `long` and stopped there. Measured: narrowing only the class property — `public uint Version` plus an explicit `long IOptimisticConcurrency.Version => Version;` — compiles and passes all 577 tests, while silently making the token 32-bit against a `bigint` column. The class property is the one EF maps to `row_version` and the one `Touch()` increments. Both are asserted now, and the mutant that previously survived dies. That is the third such test in this packet: the bound test whose clock schedule was the one under which the broken bound held, the Vogen mask test that passed with the mask removed, and this one. The pattern is always the same shape — an assertion over something adjacent to the thing that can break. **`SoftDelete` was not idempotent, and silently lost the first deleter.** A second call overwrote `deleted_at` / `deleted_by` and advanced the token again. `MarkCreated` already refuses its analogue, on the stated ground that audit-trail integrity rules out silent overwrites; `SoftDelete` now refuses for the same reason and a refused call changes nothing. A handler holding an already-deleted aggregate should have returned `Result.Fail(business_rule_violation, …)` before reaching it. **Two rules step 2 withdrew were still stated elsewhere.** `04-technical-architecture.md` called `deleted_at` / `deleted_by` optional and soft delete opt-in per aggregate; `12-localization.md`'s `tenant_template_library` still declared `updated_by uuid NOT NULL`, the exact shape step 2 proved no INSERT can satisfy, and omitted `deleted_*` and `row_version` entirely. The `add-tenant-owned-entity` input table said "Soft-deletable? Adds deleted_at/deleted_by", which is the same withdrawn claim in the file an implementer copies from. Smaller: `TenantId`'s sentinel paragraph named Packet 9 as the only consumer when Packet 7 logs the value first — the conclusion survives (a log line is not a one-way door; the `audit_log` column is) but the premise was wrong, and that paragraph exists precisely so the absence is not rediscovered. A test comment claimed the missing `New()` factory prevents `Guid.NewGuid()`, which nothing does. `typeof(TenantId).Should().NotBe()` holds for any two distinct types and no mutation can falsify it; replaced with the assertion the declarations could actually acquire — an `op_Implicit` against `Guid`. I could not construct a valid mutant for that one: Vogen exposes no cheap flag to add an implicit conversion, so it is asserted but not mutation-verified, and saying so is better than implying coverage. `TenantId` and `OrganizationId` gained glossary entries beside `UserId`. 739 tests green, 0 warnings under CI=true, format clean, link audit clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../skills/add-tenant-owned-entity/SKILL.md | 2 +- .../Domain/AuditableEntity.cs | 24 +++++++--- .../Identifiers/TenantId.cs | 12 ++--- .../Domain/AuditableEntityTests.cs | 30 +++++++++++++ .../Identifiers/TenancyIdentifierTests.cs | 44 ++++++++++++------- .../architecture/04-technical-architecture.md | 4 +- docs/architecture/12-localization.md | 11 ++++- docs/glossary.md | 2 + 8 files changed, 98 insertions(+), 31 deletions(-) diff --git a/.claude/skills/add-tenant-owned-entity/SKILL.md b/.claude/skills/add-tenant-owned-entity/SKILL.md index ecad989e..eb03f345 100644 --- a/.claude/skills/add-tenant-owned-entity/SKILL.md +++ b/.claude/skills/add-tenant-owned-entity/SKILL.md @@ -59,7 +59,7 @@ role a table-wide read of every tenant's plan. | Entity name | Yes | PascalCase. The aggregate root or owned entity. | | Owning module | Yes | Determines DbContext, namespace, migration project. | | Org-scoped? | Yes | `false` = tenant-wide; `true` = needs `OrganizationId` + org RLS. | -| Soft-deletable? | Yes | Adds `deleted_at` / `deleted_by` and an EF filter. | +| Soft-deletable? | Yes | Decides the **query filter**, not the columns: `AuditableEntity` implements `ISoftDelete` unconditionally, so `deleted_at` / `deleted_by` are on every derived table either way ([Database Standards § Audit Columns](../../../docs/standards/05-database.md)). | | Strongly-typed id | Yes | Even simple entities use `Id : strongly-typed Guid` per [02-backend-coding.md](../../../docs/standards/02-backend-coding.md). | ## Workflow diff --git a/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs b/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs index 72cb4253..6515a41d 100644 --- a/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs +++ b/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs @@ -94,17 +94,29 @@ public void MarkUpdated(DateTimeOffset at, UserId by) } /// - /// Marks the entity as soft-deleted. Also bumps - /// / so the - /// "last touched at" timestamp is monotonic — replication / sync / - /// reporting jobs that scan on UpdatedAt see soft-deletes - /// without keying off DeletedAt separately. The audit row still - /// classifies the action as a delete via its own operation type. + /// Marks the entity as soft-deleted, and stamps the update columns with the + /// same instant so a job scanning UpdatedAt sees the delete without + /// keying off DeletedAt separately. The audit row still classifies the + /// action as a delete via its own operation type. /// + /// + /// Throws when the entity is already soft-deleted, for the reason + /// throws on a second call: the second delete would + /// overwrite who deleted the row and when, and audit-trail integrity rules out + /// silent overwrites. A handler that has loaded an already-deleted aggregate + /// should refuse with Result.Fail(business_rule_violation, …) before + /// reaching this method — arriving here means the check was not made. + /// public void SoftDelete(DateTimeOffset at, UserId by) { EnsureValidAuditInput(at, by); + if (DeletedAt is not null) + { + throw new InvalidOperationException( + "This aggregate is already soft-deleted; the deleted-at / deleted-by columns are immutable after the first delete."); + } + DeletedAt = at; DeletedBy = by; Touch(at, by); diff --git a/backend/src/LearnStack.SharedKernel/Identifiers/TenantId.cs b/backend/src/LearnStack.SharedKernel/Identifiers/TenantId.cs index 0c055dc5..68271515 100644 --- a/backend/src/LearnStack.SharedKernel/Identifiers/TenantId.cs +++ b/backend/src/LearnStack.SharedKernel/Identifiers/TenantId.cs @@ -29,12 +29,14 @@ namespace LearnStack.SharedKernel.Identifiers; /// "sentinel platform tenant id" for the one row that has no tenant of its own — /// the read-sensitive audit row written inside /// EnterPlatformAdminScope, which describes a cross-tenant operation. Its -/// *value* is fixed nowhere, and the only thing that consumes it is -/// audit_log, which +/// *value* is fixed nowhere. Packet 7 is the first to emit it — a Warning +/// log line from EnterPlatformAdminScope — but a log line is not a +/// one-way door and can carry the reason and the caller without a minted id. The +/// irreversible consumer is audit_log's tenant_id column, which /// Phase 02a -/// Packet 9 owns. Choosing the value here would fix a one-way-door -/// identifier for a table that does not exist yet; Packet 9 chooses it, with the -/// schema that stores it. Note that ADR-0036 forbids the sentinel on a different +/// Packet 9 owns; choosing the value here would fix a one-way-door +/// identifier for a table that does not exist yet, so Packet 9 chooses it with +/// the schema that stores it. Note that ADR-0036 forbids the sentinel on a different /// path — an unauthenticated tenant-assertion rejection must never write under it /// — and the two rules do not conflict: one is an audited operator action, the /// other an anonymous request. diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/AuditableEntityTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/AuditableEntityTests.cs index 7e4a9d7a..3d86a638 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/AuditableEntityTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/AuditableEntityTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using LearnStack.SharedKernel.Domain; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; using Xunit; @@ -121,9 +122,38 @@ public void TheVersionIsWideEnoughForTheColumnItMapsTo() // Npgsql convention for an xmin token, which ADR-0039 rejected — and a // uint property against a bigint column round-trips wrong at the top of // the range rather than failing loudly. + // BOTH, and the second is the one that matters. Measured: narrowing only + // the class property to uint and adding an explicit `long + // IOptimisticConcurrency.Version => Version;` compiles and passes all 577 + // tests — while silently making the token 32-bit against a bigint column. + // The interface assertion alone agreed with the code rather than + // constraining it. typeof(IOptimisticConcurrency) .GetProperty(nameof(IOptimisticConcurrency.Version))! .PropertyType.Should().Be(); + + typeof(AuditableEntity) + .GetProperty(nameof(AuditableEntity.Version))! + .PropertyType.Should().Be("this is the property EF maps to row_version"); + } + + [Fact] + public void SoftDelete_CalledTwice_Throws() + { + // Same reason MarkCreated refuses a second call: the second delete would + // overwrite who deleted the row and when. + var aggregate = new TestAuditableAggregate(TestId.New()); + aggregate.MarkCreated(T0, Actor); + aggregate.SoftDelete(T0.AddDays(1), Actor); + var firstDeleter = aggregate.DeletedBy; + var versionAfterFirst = aggregate.Version; + + var act = () => aggregate.SoftDelete(T0.AddDays(2), UserId.From(Guid.CreateVersion7())); + + act.Should().Throw(); + aggregate.DeletedBy.Should().Be(firstDeleter, "the first deleter is who deleted it"); + aggregate.DeletedAt.Should().Be(T0.AddDays(1)); + aggregate.Version.Should().Be(versionAfterFirst, "a refused call changes nothing"); } [Fact] diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/TenancyIdentifierTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/TenancyIdentifierTests.cs index e65e9dc4..2b4fc943 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/TenancyIdentifierTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/TenancyIdentifierTests.cs @@ -52,8 +52,7 @@ public void RoundTripsThroughTheTwoWireFormatsItIsCarriedIn(Type idType) // API payload, TypeConverter for route binding and IConfiguration. Worth // pinning because a future declaration could narrow the mask rather than // drop it. - var id = Activator.CreateInstance(idType); - id = idType.GetMethod("From", [typeof(Guid)])!.Invoke(null, [Sample]); + var id = idType.GetMethod("From", [typeof(Guid)])!.Invoke(null, [Sample]); var json = JsonSerializer.Serialize(id, idType); JsonSerializer.Deserialize(json, idType).Should().Be(id); @@ -63,16 +62,27 @@ public void RoundTripsThroughTheTwoWireFormatsItIsCarriedIn(Type idType) .Should().Be(id); } - [Fact] - public void TheTwoIdentifiersAreNotInterchangeable() + [Theory] + [InlineData(typeof(TenantId))] + [InlineData(typeof(OrganizationId))] + public void NeitherIdentifierConvertsImplicitlyToOrFromItsPrimitive(Type idType) { - // The entire reason they are types rather than Guids. A handler that - // passed an organization where a tenant belongs is the bug the isolation - // layers cannot catch, because both are uuid at the database. - typeof(TenantId).Should().NotBe(); + // The reason they are types rather than Guids: a handler that passed an + // organization where a tenant belongs is the bug no isolation layer can + // catch, because both are `uuid` at the database. `typeof(A) != typeof(B)` + // would look like the assertion for that and is not — it holds for any two + // distinct types and no mutation of these declarations can falsify it. + // + // What CAN be acquired is an implicit conversion: Vogen emits one on + // request, and a single `op_Implicit` against Guid would make both ids + // silently interchangeable through it, restoring the primitive obsession + // ADR-0023 removed. That is falsifiable, so it is what is asserted. + idType.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static) + .Select(m => m.Name) + .Should().NotContain("op_Implicit"); TenantId.From(Sample).Value.Should().Be(OrganizationId.From(Sample).Value, - "they wrap the same primitive — which is exactly why the wrapper has to differ"); + "they wrap the same primitive — which is exactly why the wrappers must differ"); } [Theory] @@ -95,12 +105,16 @@ public void AnUninitializedIdIsReportedAsSuch(Type idType) [InlineData(typeof(OrganizationId))] public void TheseIdentifiersMintNothingOnTheirOwn(Type idType) { - // No New() / NewId() factory, deliberately. A tenant id is assigned by the - // registry that owns the Tenant aggregate — a handler that generated one - // could not satisfy the self-keyed policy's WITH CHECK. An organization id - // comes from the injected IGuidFactory so a test can pin it (Standards 02 - // § Time). Either way, `Guid.NewGuid()` inside an aggregate is the thing - // this absence prevents. + // No New() / NewId() / Create() factory, deliberately. A tenant id is + // assigned by the registry that owns the Tenant aggregate — a handler that + // generated one could not satisfy the self-keyed policy's WITH CHECK. An + // organization id comes from the injected IGuidFactory so a test can pin + // it (Standards 02 § Time). + // + // This asserts the absence of a convenience factory and nothing more. + // `X.From(Guid.NewGuid())` still compiles — nothing here can prevent that, + // and claiming otherwise would be a comment the assertion does not + // support. What stops it is review plus the IClock/IGuidFactory rule. idType.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static) .Select(m => m.Name) .Should().NotContain(["New", "NewId", "Create"]); diff --git a/docs/architecture/04-technical-architecture.md b/docs/architecture/04-technical-architecture.md index 781d2bec..351fac04 100644 --- a/docs/architecture/04-technical-architecture.md +++ b/docs/architecture/04-technical-architecture.md @@ -160,10 +160,10 @@ GraphQL is not in scope for the MVP. Revisit only when a frontend surface (e.g. | Naming | `snake_case` for tables/columns; plural table names (`users`, `course_versions`). | | Primary keys | Strongly-typed ids backed by `uuid`. | | Tenant column | `tenant_id uuid not null` on every tenant-owned table; RLS policy enforces it. | -| Audit columns | `created_at`, `created_by`, `updated_at`, `updated_by`, optional `deleted_at`, `deleted_by`. | +| Audit columns | All six on every `AuditableEntity` table: `created_at`, `created_by`, `updated_at` (null until the first update), `updated_by` (null), `deleted_at`, `deleted_by`. See [Database Standards § Audit Columns](../standards/05-database.md). | | Concurrency | `row_version bigint` (CLR `long`) on mutable entities, per [ADR-0039](../decisions/0039-optimistic-concurrency-token.md). Not `xmin`. | | Migrations | EF Core migrations; every PR includes a paired migration if the schema changes. | -| Soft delete | Opt-in per aggregate; not a global default. | +| Soft delete | The **columns** are unconditional (see above); what is opt-in per aggregate is whether it is ever soft-deleted and whether its query filter excludes deleted rows. | See [Database Standards](../standards/05-database.md). diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index 2e7a57d3..87be8bbb 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -248,10 +248,17 @@ CREATE TABLE tenant_template_library ( subject text NULL, -- channels with a subject (email) body text NOT NULL, schema_version int NOT NULL DEFAULT 1, + -- The AuditableEntity set, verbatim from Database Standards § Audit + -- Columns. updated_* are NULL because MarkCreated stamps created_* only, so + -- NOT NULL here would reject every INSERT; deleted_* are unconditional + -- because AuditableEntity implements ISoftDelete for every aggregate. created_at timestamptz NOT NULL DEFAULT now(), created_by uuid NOT NULL, - updated_at timestamptz NOT NULL DEFAULT now(), - updated_by uuid NOT NULL, + updated_at timestamptz NULL, + updated_by uuid NULL, + deleted_at timestamptz NULL, + deleted_by uuid NULL, + row_version bigint NOT NULL DEFAULT 0, -- NULLS NOT DISTINCT (PostgreSQL 15+; LearnStack pins 18 per ADR-0031) is -- load-bearing here. organization_id is null on every tenant-wide template, and a -- standard UNIQUE treats nulls as distinct, so without it a tenant could hold diff --git a/docs/glossary.md b/docs/glossary.md index 1d164d45..814aaff0 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -282,6 +282,8 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **`IntegrationEventEnvelope`** | One integration event plus the dispatch metadata the outbox row carries and the event does not: W3C `CorrelationId`, optional `OrganizationId`, `CausationId`, and causal `ActorUserId`. Its `Topic` and `PartitionKey` are the event's own — both are properties of the event *type* rather than of one delivery, so neither can hold a second answer ([ADR-0038](decisions/0038-cross-cutting-port-and-event-contracts.md)). An event implementing `IOrganizationScopedIntegrationEvent` requires a non-empty organization in its envelope. Metadata describes the *delivery*; the event describes the *fact*. | | **`IPartitionSerializer`** | Runs work sequentially within one partition key and concurrently across different ones — the in-process stand-in for what a broker gives you by assigning a partition to one consumer. It exists so the development transport carries the same ordering guarantee as the durable path rather than a weaker one. Queuing work for the key you are already inside is refused rather than deadlocked: the caller that does it is publishing from inside a handler, which [Standards 20](standards/20-infrastructure-stack.md) forbids. | | **`EventTenantContext`** | The `ITenantContext` a consumer runs under, rebuilt from the envelope by the transport before handler discovery or resolution. A consumer executes outside the request that produced the fact, so there is no ambient context to inherit. The effective principal is `UserId.SystemActor`; an envelope human remains available separately as `CausalActorUserId`. Restoring the tenant and optional organization is what makes query filters and RLS policies evaluate against the right scope. | +| **`TenantId`** | The strongly-typed tenant identifier, in `LearnStack.SharedKernel.Identifiers` per [ADR-0023 Amendment 2](decisions/0023-strongly-typed-id-source-generator.md)'s cross-cutting placement rule — it rides on `ITenantContext`, on every `[TenantOwned]` entity, in cache keys, job payloads and event envelopes, so a Tenancy-owned type would make each of those a reference to that module. It has **no `New()`**: a tenant id is assigned by the registry that owns the `Tenant` aggregate, because a handler minting its own could not satisfy the self-keyed policy's `WITH CHECK` ([Database Standards § Table classes](standards/05-database.md)). | +| **`OrganizationId`** | The strongly-typed identifier for a sub-unit within a tenant ([ADR-0017](decisions/0017-tenant-organization-hierarchy.md)), cross-cutting for the same reason as [`TenantId`]. Nullable at almost every use site, and the null is a **scope** rather than "unknown": a tenant-owned row with no organization is tenant-wide, which is why the canonical policy reads `organization_id IS NULL OR organization_id = …`. Modules hold it by value and read organization data through an application contract; the `Organization` aggregate lives only in `LearnStack.Modules.Tenancy.Domain`. | | **`UserId.SystemActor`** | The fixed, non-empty `UserId` (`00000000-0000-7000-8000-000000000001`) that integration-event consumers, background jobs and other non-request executions use as their effective audit principal — what [Audit Coverage](standards/18-audit-coverage.md) means by an actor of type `system`. It needs **no** `users` row: `created_by` and `actor_user_id` carry no referential constraint anywhere, and [31-audit-subsystem](architecture/31-audit-subsystem.md) depends on that absence so an erased actor leaves an orphan surrogate key rather than an unresolvable delete ([ADR-0038 Amendment 1](decisions/0038-cross-cutting-port-and-event-contracts.md)). The `users` table is owned by [Phase 03](roadmap/phase-03-identity-admin.md). | | **`ICacheService`** | Interface for cache reads / writes. `InMemoryCacheService` today; a Valkey-backed implementation when more than one instance runs concurrently. Tenant keys are `{tenant_id}:{module}:{logical-name}`, or `{tenant_id}:{organization_id}:{module}:{logical-name}` for organization scope, composed by `CacheKey` and enforced by `CacheKey.EnsureValid`. The only platform-wide family is the normalized host map `platform:hub:host-map:{normalized-host}`, composed by `CacheKey.ForHostMapping`; there is no generic platform factory. `RemoveByPrefixAsync` is absent per [ADR-0038](decisions/0038-cross-cutting-port-and-event-contracts.md). Set invalidation uses a caller-owned durable generation key embedded in the key template. | | **`ISecretProvider`** | Interface for secret reads. `ConfigurationSecretProvider` today; the Vault-backed implementation when a production secret must rotate without a redeploy, or more than one operator needs access to production secrets ([ADR-0035](decisions/0035-demand-gated-infrastructure.md) — *not* when a non-development deployment merely exists, which SaaS satisfies on day one). Secret namespace `learnstack/{deployment}/{module}/{key}`. | From 59828e5a5dff67641f064a3fea096bd25a82fd36 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 27 Aug 2026 20:38:45 +0300 Subject: [PATCH 08/35] fix(kernel): close the Step 2 Sonnet round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven agents, four lenses. Seven confirmed, no blockers, three refuted — and the mutation-testing lens returned an empty findings array, which after three tests in this packet that agreed with the code rather than constraining it is the result worth noting. **An update could precede creation, and the sentinel reached the row.** Neither MarkUpdated nor SoftDelete checked whether MarkCreated had run. Reproduced here: MarkUpdated on a fresh aggregate succeeded and left CreatedAt at 0001-01-01T00:00:00Z — the exact programmer-error sentinel EnsureValidAuditInput refuses as an *argument*, and whose own comment says it must fail loud rather than persist. Worse, a later MarkCreated then succeeded too, because its guard reads `CreatedAt != default` and the sentinel satisfies it, producing a row whose updated_at precedes its created_at. Both methods now refuse, and a refused call changes nothing. The ordering was one missing Create() factory call away from being wrong, with both columns populated and neither null, so nothing downstream would have noticed. `Version++` is `checked` now. 2^63 updates to one row is not a reachable bound, but an unchecked wrap would silently produce a negative token and make every subsequent ETag comparison meaningless; the cost of ruling it out is one keyword. **Both skills a Packet 6 implementer copies SQL from still carried the withdrawn audit-column shape** — `updated_at`/`updated_by NOT NULL` with no `deleted_*` at all. add-ef-migration was never touched by the previous fix commit; add-tenant-owned-entity had its prose row corrected and its CREATE TABLE block, 147 lines below, left contradicting it. Both blocks now carry the six-column set with the reason inline. standards-check's conformance checklist listed five of the six. Two documents that look like the same defect are not, and are deliberately left: `tenant_feature_flags` is a composite-keyed key/value table with no `id` and no `created_*`, and `audit_config` has no `*_by` pair at all — neither is an AuditableEntity, so NOT NULL there is satisfiable. (How those two map in EF without an id is a Step 4 question and is already on the plan's gap list.) The glossary's `AuditableEntity` entry promised monotonic "last touched", which nothing enforces and which the code comment had already dropped. It now states the three ordering guards that do exist and says the caller's IClock is what supplies forward time. 741 tests green, 0 warnings under CI=true, format clean, link audit clean. Both new guards mutation-checked: removing either fails exactly one case. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-ef-migration/SKILL.md | 11 ++++- .../skills/add-tenant-owned-entity/SKILL.md | 11 ++++- .claude/skills/standards-check/SKILL.md | 8 +++- .../Domain/AuditableEntity.cs | 42 ++++++++++++++++++- .../Domain/AuditableEntityTests.cs | 29 +++++++++++++ docs/glossary.md | 2 +- .../21-architecture-tests-catalogue.md | 7 ++-- 7 files changed, 99 insertions(+), 11 deletions(-) diff --git a/.claude/skills/add-ef-migration/SKILL.md b/.claude/skills/add-ef-migration/SKILL.md index f3de8cdb..b307ae10 100644 --- a/.claude/skills/add-ef-migration/SKILL.md +++ b/.claude/skills/add-ef-migration/SKILL.md @@ -86,10 +86,17 @@ migrationBuilder.Sql(""" tenant_id uuid NOT NULL, organization_id uuid NULL, -- only for [OrganizationScoped] -- ... domain columns ... + -- The six-column set from Database Standards § Audit Columns, verbatim. + -- updated_* are NULLABLE: MarkCreated stamps created_* only, so NOT NULL + -- here rejects every INSERT. deleted_* are UNCONDITIONAL: AuditableEntity + -- implements ISoftDelete for every aggregate, so EF maps them either way and + -- a table without them cannot materialize its own entity. created_at timestamptz NOT NULL DEFAULT now(), created_by uuid NOT NULL, - updated_at timestamptz NOT NULL DEFAULT now(), - updated_by uuid NOT NULL, + updated_at timestamptz NULL, + updated_by uuid NULL, + deleted_at timestamptz NULL, + deleted_by uuid NULL, row_version bigint NOT NULL DEFAULT 0, -- Exists solely so child tables can carry a composite FK into this one. CONSTRAINT ux__tenant_id_id UNIQUE (tenant_id, id) diff --git a/.claude/skills/add-tenant-owned-entity/SKILL.md b/.claude/skills/add-tenant-owned-entity/SKILL.md index eb03f345..747ece70 100644 --- a/.claude/skills/add-tenant-owned-entity/SKILL.md +++ b/.claude/skills/add-tenant-owned-entity/SKILL.md @@ -206,10 +206,17 @@ migrationBuilder.Sql(""" tenant_id uuid NOT NULL, organization_id uuid NULL, -- omit if not org-scoped -- ... domain columns ... + -- The six-column set from Database Standards § Audit Columns, verbatim. + -- updated_* are NULLABLE: MarkCreated stamps created_* only, so NOT NULL + -- here rejects every INSERT. deleted_* are UNCONDITIONAL: AuditableEntity + -- implements ISoftDelete for every aggregate, so EF maps them either way and + -- a table without them cannot materialize its own entity. created_at timestamptz NOT NULL DEFAULT now(), created_by uuid NOT NULL, - updated_at timestamptz NOT NULL DEFAULT now(), - updated_by uuid NOT NULL, + updated_at timestamptz NULL, + updated_by uuid NULL, + deleted_at timestamptz NULL, + deleted_by uuid NULL, row_version bigint NOT NULL DEFAULT 0, -- Exists solely so child tables can carry a composite FK into this one. -- Looks redundant next to the primary key; it is not. See the note below. diff --git a/.claude/skills/standards-check/SKILL.md b/.claude/skills/standards-check/SKILL.md index 47b218af..910a65e2 100644 --- a/.claude/skills/standards-check/SKILL.md +++ b/.claude/skills/standards-check/SKILL.md @@ -214,8 +214,12 @@ domain the diff doesn't touch. - [ ] Isolation tests for the table connect as **`learnstack_app`**, not as the owner or a `BYPASSRLS` role. A test that connects as the owner passes against an inert policy and proves nothing. -- [ ] Mutable aggregates carry the audit columns (`created_at` / - `created_by` / `updated_at` / `updated_by` / `row_version`). +- [ ] Mutable aggregates carry **all six** audit columns plus `row_version`, per + [Database Standards § Audit Columns](../../../docs/standards/05-database.md): + `created_at` / `created_by` NOT NULL, `updated_at` / `updated_by` **nullable** + (`MarkCreated` stamps neither, so NOT NULL rejects every insert), and + `deleted_at` / `deleted_by` **unconditionally** (`AuditableEntity` + implements `ISoftDelete` for every aggregate, so EF maps them either way). - [ ] Migrations forward-only by default; destructive change has a two-step plan documented. - [ ] PgBouncer transaction-pooling assumption respected (no statement-mode diff --git a/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs b/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs index 6515a41d..78c0bfc7 100644 --- a/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs +++ b/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs @@ -90,6 +90,7 @@ public void MarkCreated(DateTimeOffset at, UserId by) public void MarkUpdated(DateTimeOffset at, UserId by) { EnsureValidAuditInput(at, by); + EnsureCreated(); Touch(at, by); } @@ -111,6 +112,8 @@ public void SoftDelete(DateTimeOffset at, UserId by) { EnsureValidAuditInput(at, by); + EnsureCreated(); + if (DeletedAt is not null) { throw new InvalidOperationException( @@ -142,7 +145,44 @@ private void Touch(DateTimeOffset at, UserId by) { UpdatedAt = at; UpdatedBy = by; - Version++; + + // `checked` so the wrap is an exception rather than a sign flip. 2^63 + // updates to one row is not a reachable bound, but an unchecked ++ that + // silently produces a negative token would make every subsequent ETag + // comparison meaningless, and the cost of ruling it out is one keyword. + checked + { + Version++; + } + } + + /// + /// Refuses an update on an aggregate that was never created. + /// + /// + /// + /// Measured before this guard existed: MarkUpdated on a fresh + /// aggregate succeeded and left at + /// 0001-01-01T00:00:00Z — the exact programmer-error sentinel + /// refuses as an *argument* and which + /// its own comment says must never be persisted. Worse, a later + /// MarkCreated then succeeded, because its guard reads + /// CreatedAt != default and the sentinel still satisfied it — leaving + /// a row whose updated_at precedes its created_at. + /// + /// + /// The ordering is not something callers can be relied on to keep: it is one + /// missing Create() factory call away, and nothing downstream would + /// notice, because both columns are populated and neither is null. + /// + /// + private void EnsureCreated() + { + if (CreatedAt == default) + { + throw new InvalidOperationException( + "MarkCreated has not been called on this aggregate; an update cannot precede creation. Construct it through its aggregate factory."); + } } // Audit metadata must always be meaningful: the default timestamp diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/AuditableEntityTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/AuditableEntityTests.cs index 3d86a638..f25da1e1 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/AuditableEntityTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/AuditableEntityTests.cs @@ -137,6 +137,35 @@ public void TheVersionIsWideEnoughForTheColumnItMapsTo() .PropertyType.Should().Be("this is the property EF maps to row_version"); } + [Fact] + public void MarkUpdated_BeforeMarkCreated_Throws() + { + // Measured before the guard existed: this succeeded and left CreatedAt at + // 0001-01-01 — the programmer-error sentinel EnsureValidAuditInput refuses + // as an argument — and a later MarkCreated then succeeded too, because its + // own guard reads `CreatedAt != default` and the sentinel satisfied it. + // The row that reached the database had updated_at preceding created_at. + var aggregate = new TestAuditableAggregate(TestId.New()); + + var act = () => aggregate.MarkUpdated(T0, Actor); + + act.Should().Throw(); + aggregate.UpdatedAt.Should().BeNull("a refused call changes nothing"); + aggregate.Version.Should().Be(0); + } + + [Fact] + public void SoftDelete_BeforeMarkCreated_Throws() + { + var aggregate = new TestAuditableAggregate(TestId.New()); + + var act = () => aggregate.SoftDelete(T0, Actor); + + act.Should().Throw(); + aggregate.DeletedAt.Should().BeNull(); + aggregate.IsDeleted.Should().BeFalse(); + } + [Fact] public void SoftDelete_CalledTwice_Throws() { diff --git a/docs/glossary.md b/docs/glossary.md index 814aaff0..0ddb2943 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -256,7 +256,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **`None`** | The `readonly record struct` value used as `Result` when a command/query succeeds without returning data — replaces the `Result` with `IsSuccess = true` and `Value = null` shape Standards 09 § Forbidden bans. | | **`IClock` / `IRandom` / `IGuidFactory`** | The three deterministic-test abstractions in `LearnStack.SharedKernel`. Production code never reads `DateTime.UtcNow`, instantiates `System.Random`, or calls `Guid.NewGuid()` directly — those calls go through the abstractions so tests pin the values via `FixedClock` / `FixedRandom` / `FixedGuidFactory`. Per Standards 02 § Time. | | **`UserId`** | The cross-cutting strongly-typed actor identifier in `LearnStack.SharedKernel.Identifiers` (Vogen `[ValueObject]`). Audit columns on `AuditableEntity` reference users by `UserId` so the "no raw `Guid` on the public surface" rule (Standards 02) holds even though the Identity module lands in Phase 02b. Identity consumes the same type when it ships. | -| **`Entity` / `AuditableEntity`** | The two aggregate bases in `LearnStack.SharedKernel.Domain`. `Entity` is the append-only / audit-row base — identity, in-process domain events, identity-based equality with **uninitialized-id + cross-runtime-type guards** so `HashSet`-backed collection navigations, `Distinct()` and `Contains` behave correctly before ids are minted. EF Core's change tracker is not among the reasons — it keys on the primary-key value and tracks by reference, never calling these members. `AuditableEntity` is the mutable base — adds `CreatedAt/By`, `UpdatedAt/By`, `DeletedAt/By`, `Version`, and the `IsDeleted` projection by implementing `ISoftDelete` + `IOptimisticConcurrency`. `MarkCreated` throws on second call; `SoftDelete` also bumps `UpdatedAt` so "last touched" stays monotonic. `AuditEntry` (audit subsystem) inherits `Entity` — never `AuditableEntity` — by architecture-test rule. | +| **`Entity` / `AuditableEntity`** | The two aggregate bases in `LearnStack.SharedKernel.Domain`. `Entity` is the append-only / audit-row base — identity, in-process domain events, identity-based equality with **uninitialized-id + cross-runtime-type guards** so `HashSet`-backed collection navigations, `Distinct()` and `Contains` behave correctly before ids are minted. EF Core's change tracker is not among the reasons — it keys on the primary-key value and tracks by reference, never calling these members. `AuditableEntity` is the mutable base — adds `CreatedAt/By`, `UpdatedAt/By`, `DeletedAt/By`, `Version`, and the `IsDeleted` projection by implementing `ISoftDelete` + `IOptimisticConcurrency`. Three ordering guards, all throwing `InvalidOperationException`: `MarkCreated` refuses a second call, `SoftDelete` refuses a second delete (both because audit-trail integrity rules out silent overwrites), and `MarkUpdated` / `SoftDelete` refuse to run before `MarkCreated` — measured, without that last one an update left `CreatedAt` at the `0001-01-01` sentinel and a later `MarkCreated` produced a row whose `updated_at` preceded its `created_at`. `SoftDelete` also stamps `UpdatedAt` with the same instant so a job scanning `UpdatedAt` sees the delete; that is not a monotonicity promise, which nothing enforces — the caller's `IClock` is what supplies forward time. `AuditEntry` (audit subsystem) inherits `Entity` — never `AuditableEntity` — by architecture-test rule. | | **`IDomainEvent`** | The marker interface (`: MediatR.INotification`) every in-process domain event implements. Raised from aggregate methods, collected by the unit of work, dispatched in-process by MediatR. The abstract `DomainEvent` base declares `EventId` and `OccurredAt` as `required init` so events are always stamped through `IGuidFactory` / `IClock` at the call site. Distinct from integration events, which cross module boundaries through the outbox + `IEventBus` (`InProcessEventBus` today; Dapr pub/sub after its trigger) per [ADR-0038](decisions/0038-cross-cutting-port-and-event-contracts.md). | | **`CursorPagination` / `Page` / `PageInfo`** | The cursor-first pagination triple in `LearnStack.SharedKernel.Pagination` matching Standards 04 § Pagination. `CursorPagination(Cursor, Limit)` is the request (default `Limit = 20`, max 100; ctor throws on `Limit <= 0` — kernel-level guard); `Page(Items, PageInfo)` is the response; `PageInfo(NextCursor, PreviousCursor, HasNext, HasPrevious)` carries the opaque cursors the client never parses. | | **`LearnStackVogenDefaults.IdMask`** | The canonical `Conversions` mask every Vogen-emitted ID and value object opts into: `EfCoreValueConverter \| SystemTextJson \| TypeConverter`. Per [ADR-0023](decisions/0023-strongly-typed-id-source-generator.md) every aggregate-root ID writes `[ValueObject(LearnStackVogenDefaults.IdMask)]`. | diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 4f677154..327dea93 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -651,9 +651,10 @@ rules that need a second `DbContext` are owed by Phase 03. #### `SoftDelete_Advances_The_Row_Version` - **Asserts:** `AuditableEntity.SoftDelete` leaves `Version` strictly greater than it - was. Behavioural, because the structural rule cannot see it: `SoftDelete` stamps - `UpdatedAt` / `UpdatedBy` itself, so an increment placed only in `MarkUpdated` would - leave a soft delete un-versioned and a client's pre-delete ETag would keep + was. Behavioural, because the structural rule cannot see it: before Packet 6 step 2 + `SoftDelete` stamped `UpdatedAt` / `UpdatedBy` directly rather than through the + shared `Touch` primitive, so an increment placed only in `MarkUpdated` would have + left a soft delete un-versioned and a client's pre-delete ETag would have kept satisfying `If-Match` on the row it deleted. - **Source:** ADR-0039 § Why `MarkUpdated` and not an interceptor. - **Type:** xUnit (`LearnStack.Tests.Unit`, `AuditableEntityTests`). **Kind:** behavioural. From 1f0bf1fe4c529b19fb23aa9b4de29a0111e7274d Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 09:41:15 +0300 Subject: [PATCH 09/35] feat(infra): the four database roles and the migration credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packet 6 step 3. Until now this stack ran everything as one POSTGRES_USER, which owns every table it creates — and an owner defeats its own policies. Every isolation test would have passed against policies that constrain nothing, with no failure to observe until a tenant saw another tenant's rows in production. infra/compose/postgres-init/02-create-roles.sql creates the four roles of ADR-0003 Amendment 3, following the \gexec idempotence pattern 01 already uses because CREATE ROLE has no IF NOT EXISTS. Passwords arrive through psql's \getenv from the container environment; an unset one leaves the placeholder unbound and aborts initdb, which is the loud failure rather than four passwordless roles. GRANT CONNECT names :"db" rather than the literal `learnstack`, since POSTGRES_DB may be overridden. No per-table grant appears — the script runs before any table exists and one `relation does not exist` under ON_ERROR_STOP aborts the whole init. Measured on a fresh boot with the real init directory mounted: container up and exit 0, four roles with the declared bypass posture (app and migration NOBYPASSRLS, platform and outbox_admin BYPASSRLS), learnstack_app connects with the env password and is refused `CREATE TABLE` with `permission denied for schema public`, learnstack_migration succeeds and owns what it creates, and 01's keycloak database is untouched. Four connection strings in .env.example, documented as non-interchangeable: Migration for `make migrate` and the deploy job only, Default for every runtime DbContext, PlatformAdmin for PlatformAdminScope, OutboxDispatcher for the dispatcher. `make migrate` passes --connection explicitly rather than letting the startup project resolve one, because that would be Default — the app role, which holds USAGE but not CREATE on schema public — and the tempting fix for the resulting error is the ownership mistake above. Both of its paths are exercised: a missing variable stops with an explanation, and no module carrying migrations yet reports that rather than failing. PostgresFixture builds the roles from the compose script itself rather than a second copy, because the copy is what would drift. DatabaseRoleTests asserts the script's EFFECTS, not its text — bypass posture, non-membership of the bypass roles, the CREATE asymmetry, ownership, an ungranted table refusing the app role, a bypass role with no grant still refused, and pg_default_acl empty. One test started as a text assertion and failed on the script's own comment about a thing it does not do; it re-runs the script instead, which is the property it was named for. CI's backend-integration job activates here rather than in Packet 7, because this is the packet that ships the first Docker-bound test. The split is [Trait("Requires","Docker")] and the two jobs' filters are exact complements — verified: 11 Docker-bound plus 135 not, against 146 total, so nothing runs twice and nothing runs nowhere. The trait value is a constant rather than a repeated string precisely because a typo would belong to neither set. 764 tests green across four suites, 0 warnings under CI=true, format clean. Co-Authored-By: Claude Opus 5 (1M context) --- .config/dotnet-tools.json | 13 ++ .env.example | 31 +++ .github/workflows/ci.yml | 78 +++++-- Makefile | 37 ++++ backend/Directory.Packages.props | 6 + .../Database/DatabaseRoleTests.cs | 191 ++++++++++++++++++ .../Database/PostgresFixture.cs | 181 +++++++++++++++++ .../LearnStack.Tests.Integration.csproj | 4 + docs/roadmap/phase-02a-kernel-tenancy.md | 12 +- docs/standards/README.md | 2 +- infra/compose/dev.yml | 8 + .../compose/postgres-init/02-create-roles.sql | 83 ++++++++ 12 files changed, 624 insertions(+), 22 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs create mode 100644 infra/compose/postgres-init/02-create-roles.sql diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 00000000..9f80fafa --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.0", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} diff --git a/.env.example b/.env.example index 0bb699e0..aa64bde3 100644 --- a/.env.example +++ b/.env.example @@ -27,10 +27,41 @@ # parity, not the production source. # ─── Postgres (PostgreSQL 18 per ADR-0031) ─────────────────────────────── +# The superuser the container boots as. It owns nothing the application uses: +# every LearnStack table is owned by learnstack_migration below, because an +# owner defeats its own Row Level Security policies and every isolation test +# would then pass against policies that constrain nothing. POSTGRES_USER=learnstack POSTGRES_PASSWORD=learnstack POSTGRES_DB=learnstack +# ─── The four database roles (ADR-0003 Amendment 3) ────────────────────── +# Read by infra/compose/postgres-init/02-create-roles.sql through psql's +# \getenv on first boot. Four roles, four separate login credentials, four +# connection strings — the model is closed and a fifth role requires an ADR. +# An unset variable aborts initdb rather than creating a passwordless role. +LEARNSTACK_MIGRATION_PW=learnstack-migration-dev +LEARNSTACK_APP_PW=learnstack-app-dev +LEARNSTACK_PLATFORM_PW=learnstack-platform-dev +LEARNSTACK_OUTBOX_PW=learnstack-outbox-dev + +# ─── The four connection strings ───────────────────────────────────────── +# Double underscore is the .NET environment spelling of `ConnectionStrings:X`. +# These are NOT interchangeable and must not be unified in any environment, +# local development included — see Standards 05 § Database roles. +# +# Migration — `make migrate` and the deploy job ONLY. Never present in +# API or worker runtime configuration: the role owns every +# table, and a runtime that is the owner is the arrangement +# FORCE ROW LEVEL SECURITY exists to defeat. +# Default — every request-path and job-path DbContext. NOBYPASSRLS. +# PlatformAdmin — resolvable only by PlatformAdminScope. BYPASSRLS. +# OutboxDispatcher — the worker running OutboxProcessor, and nothing else. +ConnectionStrings__Migration=Host=localhost;Port=5432;Database=learnstack;Username=learnstack_migration;Password=learnstack-migration-dev +ConnectionStrings__Default=Host=localhost;Port=5432;Database=learnstack;Username=learnstack_app;Password=learnstack-app-dev +ConnectionStrings__PlatformAdmin=Host=localhost;Port=5432;Database=learnstack;Username=learnstack_platform;Password=learnstack-platform-dev +ConnectionStrings__OutboxDispatcher=Host=localhost;Port=5432;Database=learnstack;Username=learnstack_outbox_admin;Password=learnstack-outbox-dev + # ─── Keycloak (two realms per ADR-0004 Amendment 1) ────────────────────── KEYCLOAK_ADMIN=admin KEYCLOAK_ADMIN_PASSWORD=admin-dev-secret diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 363fd889..f627467e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,10 +19,13 @@ # see .github/CONTRIBUTING.md § Branch protection for the three edits: # - backend-integration : Testcontainers needs a real Docker socket inside # the runner — works on `ubuntu-latest` natively. Activates in Phase 02a -# Packet 7, with the first cross-tenant isolation test. The HTTP tests -# already in LearnStack.Tests.Integration do NOT wait for it: they use -# WebApplicationFactory, need no Docker, and run in the `backend` job -# today. Packet 7 splits the Docker-bound tests out into this job. +# Packet 6 — brought forward from Packet 7, because Packet 6 ships the +# first Docker-bound test (the four-role provisioning suite) and is +# therefore the packet that has to split them. The HTTP tests in +# LearnStack.Tests.Integration stay in the `backend` job: they use +# WebApplicationFactory and need no Docker. The split is by +# [Trait("Requires","Docker")] and the two jobs' filters are exact +# complements, so every test runs in exactly one of them. # - openapi-diff : oasdiff against the prior `main` spec. Activates # in Phase 02d, which ships the first real `/api/v1/*` read endpoints and # retires `/healthz` as the only documented surface. @@ -96,18 +99,22 @@ jobs: CI: "true" run: dotnet build LearnStack.slnx --no-restore --configuration Release - # No --filter. LearnStack.Tests.Integration holds WebApplicationFactory - # tests that need no Docker, and they carry rules the structural tests - # cannot: no-op'ing VersionedRouteConvention leaves every architecture - # test green and turns most of this assembly red. Excluding it meant a - # broken route convention shipped green. Testcontainers - # tests arrive in Packet 7 and move to the backend-integration job then; - # this job's NAME is a required check and does not change. + # The filter EXCLUDES a set, it does not name one. LearnStack.Tests.Integration + # holds WebApplicationFactory tests that need no Docker and carry rules the + # structural tests cannot — no-op'ing VersionedRouteConvention leaves every + # architecture test green and turns most of that assembly red — so excluding + # the assembly wholesale once let a broken route convention ship green. What + # is excluded here is only what carries [Trait("Requires","Docker")], and + # backend-integration runs exactly that complement, so every test runs once. + # Phase 02a Packet 6 brought this forward from Packet 7: it ships the first + # Docker-bound test, so it is the packet that has to split them. + # This job's NAME is a required check and does not change. - name: Test (unit + architecture + contract + HTTP integration) working-directory: backend run: | dotnet test LearnStack.slnx \ --no-restore --no-build --configuration Release \ + --filter "Requires!=Docker" \ --logger "trx;LogFileName=test-results.trx" \ --results-directory ../artifacts/backend-tests @@ -121,14 +128,51 @@ jobs: # ─── Backend integration (deferred — Testcontainers harness lights up Phase 02a) ─ backend-integration: - name: backend integration (Testcontainers — deferred) + name: backend integration (Testcontainers) runs-on: ubuntu-latest - # Disabled by default: unset vars are the empty string, so this is false until - # the repository variable is set to 'true'. Not `if: false` — actionlint rejects - # a constant condition ([if-cond]). - if: vars.ENABLE_BACKEND_INTEGRATION == 'true' + # ubuntu-latest carries a real Docker socket natively, which is why this job + # exists separately rather than the `backend` job growing a service container: + # Testcontainers manages its own lifecycle and needs the daemon, not a + # pre-declared service. + # + # The filter is the exact complement of the `backend` job's. Every test in the + # solution runs in exactly one of the two; a trait value that is neither would + # run in nowhere, which is why the value is a constant + # (LearnStack.Tests.Integration.Database.RequiresDocker) rather than a string + # repeated at each call site. steps: - - run: echo "Placeholder — Phase 02a wires the first Testcontainers integration test." + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Restore + working-directory: backend + run: dotnet restore LearnStack.slnx --locked-mode + + - name: Build + working-directory: backend + env: + CI: "true" + run: dotnet build LearnStack.slnx --no-restore --configuration Release + + - name: Test (Testcontainers) + working-directory: backend + run: | + dotnet test LearnStack.slnx \ + --no-restore --no-build --configuration Release \ + --filter "Requires=Docker" \ + --logger "trx;LogFileName=integration-results.trx" \ + --results-directory ../artifacts/backend-integration + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: backend-integration-results + path: artifacts/backend-integration + if-no-files-found: warn # ─── Frontend ─────────────────────────────────────────────────────────── frontend: diff --git a/Makefile b/Makefile index cdaa1920..41c05692 100644 --- a/Makefile +++ b/Makefile @@ -117,6 +117,43 @@ build-backend: ## `dotnet build` the solution. build-frontend: ## `pnpm -r build` the frontend monorepo. (cd frontend && pnpm -r build) +.PHONY: migrate +migrate: ## Apply every module's EF migrations as `learnstack_migration` (the ONLY sanctioned carrier of that credential). + @# Standards 05 § Database roles: ConnectionStrings:Migration must never appear + @# in API or worker runtime configuration. The role OWNS every table it creates, + @# and a runtime that is the owner is precisely the arrangement FORCE ROW LEVEL + @# SECURITY exists to defeat — every isolation test would then pass against + @# policies that constrain nothing. This target is where the credential lives. + @# + @# `--connection` is passed explicitly rather than letting the startup project + @# resolve one, because that would be ConnectionStrings:Default — the + @# learnstack_app role, which holds USAGE but not CREATE on schema public and + @# fails with `permission denied for schema public`. The tempting fix for that + @# error (granting it CREATE) is the ownership mistake above. + @set -a; test -f .env && . ./.env; set +a; \ + if [ -z "$${ConnectionStrings__Migration:-}" ]; then \ + echo "ConnectionStrings__Migration is not set."; \ + echo "It arrives with the four-role model in Phase 02a Packet 6: copy the"; \ + echo "'four database roles' and 'four connection strings' blocks out of"; \ + echo ".env.example into your .env and re-run. A .env written before that"; \ + echo "packet has neither."; \ + exit 1; \ + fi; \ + dotnet tool restore >/dev/null; \ + found=0; \ + for proj in backend/src/Modules/*/LearnStack.Modules.*.Infrastructure; do \ + test -d "$$proj/Persistence/Migrations" || continue; \ + found=1; \ + echo "==> $$(basename $$proj)"; \ + dotnet ef database update \ + --project "$$proj" \ + --startup-project backend/src/LearnStack.Api \ + --connection "$$ConnectionStrings__Migration"; \ + done; \ + if [ "$$found" = "0" ]; then \ + echo "No module carries Persistence/Migrations yet — the first lands with the Tenancy schema in Phase 02a Packet 6."; \ + fi + .PHONY: sdk sdk: ## Regenerate @learnstack/sdk types from a running API's OpenAPI document. @# Needs the API up. `make dev` starts the compose stack, NOT the API — run diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 1c86ea69..1a23169b 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -14,6 +14,12 @@ + + diff --git a/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs new file mode 100644 index 00000000..ad2804f9 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs @@ -0,0 +1,191 @@ +using FluentAssertions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// The four-role model of +/// ADR-0003 +/// Amendment 3, asserted against the script the compose stack actually runs. +/// +/// +/// +/// These exist because the roles are the layer everything else rests on and the +/// only one whose absence is silent. With one shared superuser the schema still +/// builds, the migrations still apply, and every isolation test still passes — +/// against policies that constrain nothing, because the connecting role owns the +/// tables. There is no failure to observe until a tenant sees another tenant's +/// rows in production. +/// +/// +/// They assert the script's effects rather than its text. A test that +/// grepped the SQL for NOBYPASSRLS would pass on a script that never ran. +/// +/// +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class DatabaseRoleTests : IClassFixture +{ + private readonly PostgresFixture _postgres; + + public DatabaseRoleTests(PostgresFixture postgres) => _postgres = postgres; + + [Theory] + [InlineData("learnstack_migration", false)] + [InlineData("learnstack_app", false)] + [InlineData("learnstack_platform", true)] + [InlineData("learnstack_outbox_admin", true)] + public async Task EachRoleExistsWithItsDeclaredBypassPosture(string role, bool expectedBypass) + { + await using var connection = await PostgresFixture.OpenAsync(_postgres.MigrationConnectionString); + await using var command = new NpgsqlCommand( + "SELECT rolbypassrls, rolcanlogin FROM pg_roles WHERE rolname = @role", (NpgsqlConnection)connection); + command.Parameters.AddWithValue("role", role); + + await using var reader = await command.ExecuteReaderAsync(); + + (await reader.ReadAsync()).Should().BeTrue($"{role} must exist"); + reader.GetBoolean(0).Should().Be(expectedBypass); + reader.GetBoolean(1).Should().BeTrue($"{role} logs in for itself — per-role settings are applied at login and do not follow SET ROLE"); + } + + [Fact] + public async Task TheApplicationRoleIsNotAMemberOfTheBypassRoles() + { + // Membership would make BYPASSRLS a standing capability of the request-path + // role, reachable from any code path that can execute SET ROLE — and a plain + // SET ROLE survives COMMIT on a transaction-pooled connection, into the next + // tenant's request. EnterPlatformAdminScope uses a second credentialed + // connection instead. + await using var connection = await PostgresFixture.OpenAsync(_postgres.MigrationConnectionString); + await using var command = new NpgsqlCommand( + """ + SELECT count(*) + FROM pg_auth_members m + JOIN pg_roles member ON member.oid = m.member + JOIN pg_roles granted ON granted.oid = m.roleid + WHERE member.rolname = 'learnstack_app' + AND granted.rolname IN ('learnstack_platform', 'learnstack_outbox_admin') + """, (NpgsqlConnection)connection); + + (await command.ExecuteScalarAsync()).Should().Be(0L); + } + + [Fact] + public async Task OnlyTheMigrationRoleMayCreateInSchemaPublic() + { + // The asymmetry the first migration depends on. Since PostgreSQL 15 the + // public schema grants CREATE to nobody by default; without the grant the + // migration fails with "permission denied for schema public", and the + // tempting fix — granting the application role CREATE, or making it the + // owner — reinstates the arrangement FORCE ROW LEVEL SECURITY defeats. + await using var migration = await PostgresFixture.OpenAsync(_postgres.MigrationConnectionString); + await using var create = new NpgsqlCommand( + "CREATE TABLE role_probe (id int)", (NpgsqlConnection)migration); + await create.ExecuteNonQueryAsync(); + + await using var app = await PostgresFixture.OpenAsync(_postgres.AppConnectionString); + await using var denied = new NpgsqlCommand( + "CREATE TABLE app_probe (id int)", (NpgsqlConnection)app); + + var act = async () => await denied.ExecuteNonQueryAsync(); + + (await act.Should().ThrowAsync()) + .Which.SqlState.Should().Be(PostgresErrorCodes.InsufficientPrivilege); + } + + [Fact] + public async Task TheMigrationRoleOwnsWhatItCreates_AndOwnershipGrantsNoBypass() + { + // No ALTER TABLE ... OWNER TO appears in any migration: the role that runs + // them IS the owner, and seeing an explicit OWNER TO is a sign the migration + // ran as the wrong role. Ownership still buys no bypass, because the + // migration role is NOBYPASSRLS and every table declares FORCE. + await using var migration = await PostgresFixture.OpenAsync(_postgres.MigrationConnectionString); + await using var create = new NpgsqlCommand( + "CREATE TABLE owner_probe (id int)", (NpgsqlConnection)migration); + await create.ExecuteNonQueryAsync(); + + await using var owner = new NpgsqlCommand( + "SELECT tableowner FROM pg_tables WHERE tablename = 'owner_probe'", + (NpgsqlConnection)migration); + + (await owner.ExecuteScalarAsync()).Should().Be("learnstack_migration"); + } + + [Fact] + public async Task ANewTableGrantsTheApplicationRoleNothingUntilItsMigrationSaysSo() + { + // There is deliberately no ALTER DEFAULT PRIVILEGES. A table nobody granted + // fails loudly with `permission denied` rather than silently inheriting DML + // — and can never silently widen a BYPASSRLS role, whose only bound is the + // grant matrix. + await using var migration = await PostgresFixture.OpenAsync(_postgres.MigrationConnectionString); + await using var create = new NpgsqlCommand( + "CREATE TABLE ungranted_probe (id int)", (NpgsqlConnection)migration); + await create.ExecuteNonQueryAsync(); + + await using var app = await PostgresFixture.OpenAsync(_postgres.AppConnectionString); + await using var read = new NpgsqlCommand( + "SELECT count(*) FROM ungranted_probe", (NpgsqlConnection)app); + + var act = async () => await read.ExecuteScalarAsync(); + + (await act.Should().ThrowAsync()) + .Which.SqlState.Should().Be(PostgresErrorCodes.InsufficientPrivilege); + } + + [Fact] + public async Task ABypassRoleWithNoGrantIsStillRefused() + { + // BYPASSRLS bypasses policies, not GRANTs. Stating it as a test because the + // grant matrix is the whole of the bound on both bypass roles, and a reader + // who believes the attribute is the bound will not maintain the matrix. + await using var migration = await PostgresFixture.OpenAsync(_postgres.MigrationConnectionString); + await using var create = new NpgsqlCommand( + "CREATE TABLE bypass_probe (id int)", (NpgsqlConnection)migration); + await create.ExecuteNonQueryAsync(); + + await using var platform = await PostgresFixture.OpenAsync(_postgres.PlatformConnectionString); + await using var read = new NpgsqlCommand( + "SELECT count(*) FROM bypass_probe", (NpgsqlConnection)platform); + + var act = async () => await read.ExecuteScalarAsync(); + + (await act.Should().ThrowAsync()) + .Which.SqlState.Should().Be(PostgresErrorCodes.InsufficientPrivilege); + } + + [Fact] + public async Task TheRolesScriptIsIdempotent() + { + // The compose init directory runs once per fresh volume, but a developer + // re-running it by hand against an existing cluster must not get + // `role "learnstack_app" already exists`, which under ON_ERROR_STOP aborts + // the rest of the script. CREATE ROLE has no IF NOT EXISTS, so the guard is + // the \gexec form — and the only way to know it works is to run it twice. + // + // Asserted by re-running rather than by grepping the file for the guard: a + // text assertion passes on a script that never executes, and the first + // version of this test failed on the script's own COMMENT about a thing it + // does not do. + var result = await _postgres.RunRolesScriptAgainAsync(); + + result.ExitCode.Should().Be(0, "a re-run must be a no-op, not an error: {0}", result.Stderr); + } + + [Fact] + public async Task NoDefaultPrivilegesExist_SoAnUngrantedTableIsAlwaysLoud() + { + // The effect of there being no ALTER DEFAULT PRIVILEGES anywhere. + // pg_default_acl holds one row per default-privilege rule; empty means a + // table nobody granted inherits nothing, so it fails with + // `permission denied` instead of silently widening a BYPASSRLS role — + // whose only bound is the grant matrix. + await using var connection = await PostgresFixture.OpenAsync(_postgres.MigrationConnectionString); + await using var command = new NpgsqlCommand( + "SELECT count(*) FROM pg_default_acl", (NpgsqlConnection)connection); + + (await command.ExecuteScalarAsync()).Should().Be(0L); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs new file mode 100644 index 00000000..b5a7baa4 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs @@ -0,0 +1,181 @@ +using System.Data.Common; +using Npgsql; +using Testcontainers.PostgreSql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// The trait that partitions this assembly between CI's two backend jobs. +/// +/// +/// +/// LearnStack.Tests.Integration holds two kinds of test. The +/// WebApplicationFactory HTTP tests need no Docker and run in the +/// backend job with everything else. Anything carrying this trait needs a +/// real Docker socket and runs in backend-integration. +/// +/// +/// The two filters must stay exact complements. `backend` runs +/// --filter "Requires!=Docker" and `backend-integration` runs +/// --filter "Requires=Docker", so every test runs exactly once. A +/// mistyped trait value belongs to neither set and would run nowhere — green in +/// both jobs, executed in neither, which is the failure mode the `backend` job's +/// own comment warns about after a filter once hid a broken route convention. +/// Hence one constant rather than a string at each call site. +/// +/// +internal static class RequiresDocker +{ + public const string Key = "Requires"; + public const string Value = "Docker"; +} + +/// +/// A real PostgreSQL 18 with LearnStack's four-role model provisioned, shared by +/// every test that needs a database. +/// +/// +/// +/// Postgres only. Not Valkey, not Kafka — nothing the backend runs calls +/// either, and both sit behind the gated compose profile +/// (ADR-0035). +/// A fixture that started them would make every data test pay for containers no +/// assertion touches. +/// +/// +/// The four roles are the point. The container's own superuser owns +/// nothing LearnStack uses: owns every +/// table and is what tests connect as. A test +/// that connected as the owner — or as either BYPASSRLS role — would pass +/// against policies that constrain nothing, which is the failure mode +/// ADR-0003 +/// Amendment 3 names by hand. That is why this fixture exposes four +/// connection strings and not one. +/// +/// +/// The roles are created by the same SQL the compose stack runs, read from +/// infra/compose/postgres-init/02-create-roles.sql rather than restated +/// here — a second copy is a second thing to keep true, and the copy is what +/// would drift. It is executed through psql inside the container because +/// the script uses psql client directives (\getenv, \gexec) that a +/// Npgsql command cannot interpret. +/// +/// +public sealed class PostgresFixture : IAsyncLifetime +{ + private const string Database = "learnstack"; + private const string MigrationPassword = "migration-test"; + private const string AppPassword = "app-test"; + private const string PlatformPassword = "platform-test"; + private const string OutboxPassword = "outbox-test"; + private const string ContainerScriptPath = "/tmp/02-create-roles.sql"; + + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() + // Pinned to the tag infra/compose/dev.yml runs. A fixture on a different + // major would test a database no deployment uses. + .WithImage("postgres:18.4-alpine") + .WithDatabase(Database) + .WithUsername("postgres") + .WithPassword("postgres") + .WithEnvironment("LEARNSTACK_MIGRATION_PW", MigrationPassword) + .WithEnvironment("LEARNSTACK_APP_PW", AppPassword) + .WithEnvironment("LEARNSTACK_PLATFORM_PW", PlatformPassword) + .WithEnvironment("LEARNSTACK_OUTBOX_PW", OutboxPassword) + .Build(); + + /// Owns every table. `dotnet ef database update` and nothing else. + public string MigrationConnectionString => For("learnstack_migration", MigrationPassword); + + /// What a test connects as. NOBYPASSRLS. + public string AppConnectionString => For("learnstack_app", AppPassword); + + /// BYPASSRLS; only PlatformAdminScope's equivalent. + public string PlatformConnectionString => For("learnstack_platform", PlatformPassword); + + /// BYPASSRLS; only the outbox dispatcher's equivalent. + public string OutboxConnectionString => For("learnstack_outbox_admin", OutboxPassword); + + public async Task InitializeAsync() + { + await _container.StartAsync(); + + var script = await File.ReadAllTextAsync(RepositoryPath.RolesScript()); + await _container.CopyAsync(System.Text.Encoding.UTF8.GetBytes(script), ContainerScriptPath); + + // ON_ERROR_STOP so a failure here fails the fixture rather than leaving a + // half-provisioned cluster that produces a confusing error in the first + // test that touches it. + var result = await _container.ExecAsync( + [ + "psql", "-v", "ON_ERROR_STOP=1", "-U", "postgres", "-d", Database, "-f", ContainerScriptPath, + ]); + + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"Provisioning the four database roles failed (exit {result.ExitCode}).{Environment.NewLine}" + + $"{result.Stderr}{Environment.NewLine}{result.Stdout}"); + } + } + + public async Task DisposeAsync() => await _container.DisposeAsync(); + + /// + /// Runs the roles script a second time against the already-provisioned + /// cluster, so a test can assert that a re-run is a no-op rather than an error. + /// + public Task RunRolesScriptAgainAsync() => + _container.ExecAsync( + [ + "psql", "-v", "ON_ERROR_STOP=1", "-U", "postgres", "-d", Database, + "-f", ContainerScriptPath, + ]); + + /// Opens a connection as the given role and returns it open. + public static async Task OpenAsync( + string connectionString, CancellationToken cancellationToken = default) + { + var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(cancellationToken); + return connection; + } + + private string For(string role, string password) => + $"Host={_container.Hostname};Port={_container.GetMappedPublicPort(5432)};" + + $"Database={Database};Username={role};Password={password};" + // The fixture's roles are created per container, so pooling across + // connection strings is safe — but Include Error Detail turns a policy + // rejection into a message that names the constraint, which is the + // difference between a useful failure and "23514". + + "Include Error Detail=true"; +} + +/// +/// Locates repository files from a test host whose working directory is +/// bin/Debug/net10.0. +/// +/// +/// A relative path resolves against that directory, where nothing exists — the +/// query silently yields nothing and the assertion over it passes. Walking up to +/// the repository root is the only form that fails loudly when it is wrong. +/// +internal static class RepositoryPath +{ + public static string RolesScript() => + Path.Combine(Root(), "infra", "compose", "postgres-init", "02-create-roles.sql"); + + private static string Root() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "CLAUDE.md"))) + { + directory = directory.Parent; + } + + return directory?.FullName + ?? throw new InvalidOperationException( + $"Repository root not found above {AppContext.BaseDirectory}."); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj b/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj index e5809a20..356c8e18 100644 --- a/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj +++ b/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj @@ -25,6 +25,10 @@ + + diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index 16ad5d54..624cb265 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -642,10 +642,14 @@ re-stated against reality: a standard with no implementing code moves from `Active` to `Adopted`. All twenty-two currently claim `Active`, which makes the three-state model decorative. -The deferred `backend-integration` CI job -([Phase 01 Packet 8](phase-01-repository-tooling.md)) activates once Packet 7's first -isolation test is green — `vars.ENABLE_BACKEND_INTEGRATION` set, the placeholder step -replaced, and the job renamed and re-required per +The `backend-integration` CI job +([Phase 01 Packet 8](phase-01-repository-tooling.md)) **activated in Packet 6**, one +packet earlier than planned: Packet 6 ships the first Docker-bound test — the +four-role provisioning suite — so it is the packet that has to split them. The +`vars.ENABLE_BACKEND_INTEGRATION` gate and the placeholder step are gone; the job +restores, builds and runs `--filter "Requires=Docker"`, and the `backend` job runs +the exact complement, so every test runs in exactly one of the two. Making it a +required check is a repository setting, per [`.github/CONTRIBUTING.md`](../../.github/CONTRIBUTING.md). Closes the architecture-test arm of the [Phase Exit Decision](#phase-exit-decision); the remaining gates close as their owning packets ship. diff --git a/docs/standards/README.md b/docs/standards/README.md index 45c41653..5c7e905d 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -84,7 +84,7 @@ enforced" change in one commit. Until that lands, **this table wins**. | 03 | [Frontend Coding](03-frontend-coding.md) | **Adopted** | ESLint and TypeScript strict mode are configured, and Packet 3b stood up the Vitest harness (jsdom + Testing Library) with one render test — so the required `frontend` check now asserts something. `apps/web` is otherwise still a scaffold with no components. First real code: [Phase 02d](../roadmap/phase-02d-walking-skeleton.md). | | 04 | [API Design](04-api-design.md) | **Active** | Packet 4 shipped the versioned route convention and its startup guards, one Problem Details shape on every error including the framework-minted ones, cursor pagination, the sort grammar, idempotency keys, ETag concurrency, correlation ids, the request-body limit and the tenancy edge — each with tests in the required `backend` check. No *business* endpoint exists yet; the conventions they will land into do. | | 05 | [Database](05-database.md) | **Adopted** | No `DbContext` and no migration exist. The canonical RLS template it now owns is applied by Packet 6's first migration. | -| 06 | [Testing](06-testing.md) | **Active** | Unit, architecture, contract **and** integration suites all run in the required `backend` job — Packet 4 removed the filter that used to exclude the integration assembly, which by then held the only tests that could catch an unversioned route. The separate Docker-bound `backend-integration` job stays gated on an unset `vars.ENABLE_BACKEND_INTEGRATION` until Packet 7 lands the first Testcontainers isolation test. | +| 06 | [Testing](06-testing.md) | **Active** | Unit, architecture, contract **and** integration suites all run in the required `backend` job — Packet 4 removed the filter that used to exclude the integration assembly, which by then held the only tests that could catch an unversioned route. The Docker-bound `backend-integration` job activated in Packet 6 with the four-role provisioning suite; the split is by `[Trait("Requires","Docker")]` and the two jobs' filters are exact complements. | | 07 | [Frontend Architecture](07-frontend-architecture.md) | **Adopted** | Route groups exist as empty layouts; server/client split, tenant context and SDK shape are exercised first in [Phase 02d](../roadmap/phase-02d-walking-skeleton.md). | | 08 | [Localization](08-localization.md) | **Adopted** | `tenant_locales` and the slug schema land in Packet 6; the i18n runtime in [Phase 04](../roadmap/phase-04-cms-media-pages.md). | | 09 | [Error Handling](09-error-handling.md) | **Active** | L1 `IExceptionHandler`, the exception hierarchy, `ProblemDetailsFactory` and `HttpStatusMap` shipped in Packet 3. | diff --git a/infra/compose/dev.yml b/infra/compose/dev.yml index dab38389..4d84743d 100644 --- a/infra/compose/dev.yml +++ b/infra/compose/dev.yml @@ -60,6 +60,14 @@ services: POSTGRES_USER: ${POSTGRES_USER:-learnstack} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-learnstack} POSTGRES_DB: ${POSTGRES_DB:-learnstack} + # Read by postgres-init/02-create-roles.sql through psql's \getenv on the + # first boot of a fresh volume. The four-role model of ADR-0003 + # Amendment 3; an unset one aborts initdb rather than creating a + # passwordless role. + LEARNSTACK_MIGRATION_PW: ${LEARNSTACK_MIGRATION_PW:-learnstack-migration-dev} + LEARNSTACK_APP_PW: ${LEARNSTACK_APP_PW:-learnstack-app-dev} + LEARNSTACK_PLATFORM_PW: ${LEARNSTACK_PLATFORM_PW:-learnstack-platform-dev} + LEARNSTACK_OUTBOX_PW: ${LEARNSTACK_OUTBOX_PW:-learnstack-outbox-dev} # Dev-only. Production secrets come from Vault via ISecretProvider. ports: - "127.0.0.1:5432:5432" diff --git a/infra/compose/postgres-init/02-create-roles.sql b/infra/compose/postgres-init/02-create-roles.sql new file mode 100644 index 00000000..b1198dfc --- /dev/null +++ b/infra/compose/postgres-init/02-create-roles.sql @@ -0,0 +1,83 @@ +-- Postgres init script — runs ONCE on a fresh `postgres-data` volume, after +-- 01-create-keycloak-db.sql. Provisions the four-role model of +-- ADR-0003 Amendment 3, whose canonical definition is +-- docs/standards/05-database.md § Database roles. +-- +-- WHY FOUR ROLES AND NOT ONE. The single `POSTGRES_USER` this stack used to run +-- everything as owns every table it creates, and an owner bypasses its own +-- policies unless FORCE ROW LEVEL SECURITY is set — and even under FORCE, a +-- runtime that IS the owner defeats the separation the policies exist to make. +-- Every isolation test would then pass against policies that constrain nothing. +-- +-- PASSWORDS COME FROM THE ENVIRONMENT. `\getenv` reads each one from the +-- container env, which infra/compose/dev.yml supplies as `${VAR:-…}` with a +-- matching row in `.env.example`, per Standards 12 § Local Infrastructure — +-- compose files carry no bare credential literals. If a variable is unset the +-- placeholder stays unbound and this script fails, which aborts initdb and stops +-- the container: a loud failure rather than four passwordless roles. + +\getenv migration_pw LEARNSTACK_MIGRATION_PW +\getenv app_pw LEARNSTACK_APP_PW +\getenv platform_pw LEARNSTACK_PLATFORM_PW +\getenv outbox_pw LEARNSTACK_OUTBOX_PW +\getenv db POSTGRES_DB + +-- Idempotent in the shape 01-create-keycloak-db.sql already uses: PostgreSQL has +-- no CREATE ROLE IF NOT EXISTS, so the statement is generated only when the role +-- is absent and executed with \gexec. `format(%L)` quotes the password for SQL; +-- psql has already substituted `:'x'` into a literal, so the value is never +-- concatenated into the statement text unescaped. +SELECT format('CREATE ROLE learnstack_migration LOGIN PASSWORD %L NOBYPASSRLS', :'migration_pw') +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'learnstack_migration') +\gexec + +SELECT format('CREATE ROLE learnstack_app LOGIN PASSWORD %L NOBYPASSRLS', :'app_pw') +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'learnstack_app') +\gexec + +-- BYPASSRLS on these two is bounded by GRANTs, not by policies: the attribute +-- bypasses policies and nothing else, so a role holding it with no table +-- privilege gets `permission denied for table`. The GRANT matrix in +-- Standards 05 is the whole of that bound, and every grant is written in the +-- migration that creates its table — there is deliberately no +-- ALTER DEFAULT PRIVILEGES, so a new table nobody granted fails loudly instead +-- of silently widening a bypass role. +SELECT format('CREATE ROLE learnstack_platform LOGIN PASSWORD %L BYPASSRLS', :'platform_pw') +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'learnstack_platform') +\gexec + +SELECT format('CREATE ROLE learnstack_outbox_admin LOGIN PASSWORD %L BYPASSRLS', :'outbox_pw') +WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'learnstack_outbox_admin') +\gexec + +-- No `GRANT learnstack_platform TO learnstack_app`, ever. Membership would make +-- BYPASSRLS a standing capability of the application role, reachable from any +-- code path that can execute `SET ROLE` — and a plain SET ROLE survives COMMIT on +-- a PgBouncer transaction-pooled connection, into the next tenant's request. +-- EnterPlatformAdminScope reaches the platform role by a second, separately +-- credentialed connection instead (Standards 05 § How EnterPlatformAdminScope +-- reaches learnstack_platform). + +-- :"db" quotes as an identifier. The database name is POSTGRES_DB, which .env +-- may override, so the literal `learnstack` would grant CONNECT on a database +-- that need not exist. Note this grants nothing on the `keycloak` database that +-- 01 creates — Keycloak keeps its own role, and these four have no business +-- there. +GRANT CONNECT ON DATABASE :"db" + TO learnstack_migration, learnstack_app, learnstack_platform, learnstack_outbox_admin; + +-- Since PostgreSQL 15 the public schema no longer grants CREATE to PUBLIC, and +-- the schema is owned by pg_database_owner. Without the CREATE grant below the +-- first migration fails with "permission denied for schema public" — and the +-- tempting fix, making the migration role a superuser or the database owner, +-- reinstates exactly the ownership arrangement FORCE ROW LEVEL SECURITY exists +-- to defeat. +REVOKE ALL ON SCHEMA public FROM PUBLIC; +GRANT USAGE, CREATE ON SCHEMA public TO learnstack_migration; +GRANT USAGE ON SCHEMA public + TO learnstack_app, learnstack_platform, learnstack_outbox_admin; + +-- No per-table grants here. This script runs at initdb time, before any table +-- exists, and under the entrypoint's ON_ERROR_STOP a single +-- `relation "…" does not exist` aborts the whole init and the container never +-- becomes healthy. Table grants live in the migration that creates each table. From 10f3df3a29401b5a2b44f2e694b5872e7440a60c Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 10:19:46 +0300 Subject: [PATCH 10/35] fix(infra): close the Step 3 Opus round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten agents, five lenses, the load-bearing findings re-measured here. One blocker, thirteen major, twenty minor — and the two worst were in the two artefacts whose whole job is to keep the migration credential in one place. **`make migrate` never delivered the credential it exists to isolate.** The recipe shell-sourced `.env`, and a connection string contains semicolons, which `. ./.env` on an unquoted row parses as statement separators. Reproduced: ConnectionStrings__Migration arrived as `Host=localhost`, and `Port`, `Database`, `Username`, `Password` leaked into the environment as bare variables. The `-z` guard passed on that non-empty value and `dotnet ef` would have got `--connection "Host=localhost"`. Latent today because no module carries migrations, and it would have surfaced in step 4 as an authentication error whose obvious local fix is the ownership mistake the target was written to prevent. Three changes: `.env.example` quotes its four values; the recipe reads the one key with `sed` rather than sourcing, so a `.env` written before this packet still yields the whole string; and the guard rejects a value that does not name `learnstack_migration`, because emptiness is not the failure mode that occurs. **`make migrate` also reported success after every migration failed.** `-e` does not abort on a failure inside a for-loop body that is part of a compound list — measured, both iterations ran after `false` and the recipe exited 0. The loop carries the status explicitly now; measured again with a probe module, exit is non-zero. **The blocker: `backend-integration` pointed setup-dotnet at a repo-root global.json that does not exist** (the only one is backend/global.json), so the job would have died before running a test. It uses DOTNET_SDK_VERSION like the `backend` job, and gained the timeout-minutes and persist-credentials the other jobs have. The roles script now revokes CONNECT and TEMPORARY from PUBLIC before granting — measured, without it the four explicit grants added nothing, because PUBLIC holds both by default. And a comment of mine was falsified by my own measurement: I wrote that after the revoke the roles have no reach into the `keycloak` database either. They still do; the revoke names one database. The comment says what is true and why it is accepted. Test hardening, all of it the same class — an assertion adjacent to the thing that can break: - rolbypassrls was checked, rolsuper was not. A superuser bypasses RLS whatever that column says, so one CREATE ROLE … SUPERUSER would defeat the model and pass. Also rolcreatedb and rolcreaterole, either of which is a path back. - Membership was queried through pg_auth_members, which sees only DIRECT edges. Membership is transitive; pg_has_role asks the question actually being asked. - learnstack_outbox_admin's credential was never opened by any test. All four log in now, and the role that authenticates must be the one the string named. - TheMigrationRoleOwnsWhatItCreates_AndOwnershipGrantsNoBypass asserted only the first half of its name. The second half is now a policy the owner is refused by, which is what FORCE buys. And a rationale of mine was simply wrong: I wrote that a mistyped trait value "would run nowhere". Measured — `Requires!=Docker` matches every test with no Requires trait, so a typo runs in the `backend` job, where there is no daemon. A loud failure in the job that cannot fix it, which is still a reason for the constant, stated correctly. Corpus: Standards 05 § Database roles said the .env rows and compose entries do not exist yet and that its fence is the shipped script; CONTRIBUTING still filed backend-integration as a deferred variable-gated Packet 7 check; the compose README documented one init script and gave no recovery for a pre-existing volume, which is the failure a developer will actually hit; two Makefile comments and 06-testing, the roadmap, the ci.yml header, local-dev-setup and add-ef-migration all still described the pre-Packet-6 world. 765 tests green across four suites; the CI partition is still exact at 12 + 135 against 147. Format clean, link audit clean, ci.yml parses. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-ef-migration/SKILL.md | 15 +++- .claude/skills/local-dev-setup/SKILL.md | 18 +++++ .env.example | 8 +-- .github/CONTRIBUTING.md | 10 ++- .github/workflows/ci.yml | 43 ++++++++---- Makefile | 57 ++++++++++++---- .../Database/DatabaseRoleTests.cs | 68 ++++++++++++++++--- .../Database/PostgresFixture.cs | 41 ++++++++--- docs/roadmap/phase-02a-kernel-tenancy.md | 10 +-- docs/standards/05-database.md | 18 +++-- docs/standards/06-testing.md | 2 +- infra/compose/README.md | 28 ++++++-- .../compose/postgres-init/02-create-roles.sql | 25 +++++-- 13 files changed, 269 insertions(+), 74 deletions(-) diff --git a/.claude/skills/add-ef-migration/SKILL.md b/.claude/skills/add-ef-migration/SKILL.md index b307ae10..f0a5b404 100644 --- a/.claude/skills/add-ef-migration/SKILL.md +++ b/.claude/skills/add-ef-migration/SKILL.md @@ -308,9 +308,18 @@ dotnet ef database update \ ``` `ConnectionStrings__Migration` is the environment spelling of -`ConnectionStrings:Migration`. Packet 6 adds it to `.env.example` and exports it -from the repo-root `make migrate` target, which Standards 05 names as its only -sanctioned carrier. +`ConnectionStrings:Migration`. It is in `.env.example` from Packet 6, and +`make migrate` is its only sanctioned carrier per Standards 05 — **prefer that +target over this command**, which is shown for the case where you need one +module rather than all of them. + +Two things `make migrate` does that a hand-run does not. It reads the value out +of `.env` with `sed` rather than sourcing the file, because a connection string +contains semicolons and `. ./.env` on an unquoted row parses them as statement +separators — measured, the value arrived as `Host=localhost`. And it refuses a +value that does not name `learnstack_migration`, because the failure mode is not +an empty variable but a truncated or wrong-role one, whose obvious local fix is +the ownership mistake the role split exists to prevent. Then run the architecture + integration test suite. The Testcontainers integration tests automatically apply migrations on a fresh Postgres; a green run means the diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index 4f34c008..6b01ef38 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -98,11 +98,29 @@ the API or the web app, and it does not run migrations or seeds; those are separate commands you run yourself: ```bash +make migrate # apply every module's EF migrations dotnet run --project backend/src/LearnStack.Api # API on 5080 pnpm --filter @learnstack/web dev # web on 3000 make seed # health gate + demo credentials ``` +**`make migrate` runs as `learnstack_migration`, not as the API's role.** From +Phase 02a Packet 6 the stack provisions four database roles on the first boot of +a fresh `postgres-data` volume +([ADR-0003 Amendment 3](../../../docs/decisions/0003-tenant-isolation-defense-in-depth.md)): +`learnstack_migration` owns every table, `learnstack_app` is what the API +connects as, and `learnstack_platform` / `learnstack_outbox_admin` hold audited +bypasses. Four roles, four passwords, four connection strings — all in +`.env.example`, and none of them interchangeable. Running migrations as the +runtime role would make it the table owner, which is the arrangement +`FORCE ROW LEVEL SECURITY` exists to defeat. + +**A volume created before that packet has no roles**, and nothing says so: init +scripts do not re-run, `make dev` reports healthy, and `make migrate` fails with +`password authentication failed`. Recovery is in +[`infra/compose/README.md`](../../../infra/compose/README.md) — `make clean` then +`make dev`, or apply `02-create-roles.sql` by hand, which is idempotent. + What `make dev` expands to: ```bash diff --git a/.env.example b/.env.example index aa64bde3..e78cf793 100644 --- a/.env.example +++ b/.env.example @@ -57,10 +57,10 @@ LEARNSTACK_OUTBOX_PW=learnstack-outbox-dev # Default — every request-path and job-path DbContext. NOBYPASSRLS. # PlatformAdmin — resolvable only by PlatformAdminScope. BYPASSRLS. # OutboxDispatcher — the worker running OutboxProcessor, and nothing else. -ConnectionStrings__Migration=Host=localhost;Port=5432;Database=learnstack;Username=learnstack_migration;Password=learnstack-migration-dev -ConnectionStrings__Default=Host=localhost;Port=5432;Database=learnstack;Username=learnstack_app;Password=learnstack-app-dev -ConnectionStrings__PlatformAdmin=Host=localhost;Port=5432;Database=learnstack;Username=learnstack_platform;Password=learnstack-platform-dev -ConnectionStrings__OutboxDispatcher=Host=localhost;Port=5432;Database=learnstack;Username=learnstack_outbox_admin;Password=learnstack-outbox-dev +ConnectionStrings__Migration='Host=localhost;Port=5432;Database=learnstack;Username=learnstack_migration;Password=learnstack-migration-dev' +ConnectionStrings__Default='Host=localhost;Port=5432;Database=learnstack;Username=learnstack_app;Password=learnstack-app-dev' +ConnectionStrings__PlatformAdmin='Host=localhost;Port=5432;Database=learnstack;Username=learnstack_platform;Password=learnstack-platform-dev' +ConnectionStrings__OutboxDispatcher='Host=localhost;Port=5432;Database=learnstack;Username=learnstack_outbox_admin;Password=learnstack-outbox-dev' # ─── Keycloak (two realms per ADR-0004 Amendment 1) ────────────────────── KEYCLOAK_ADMIN=admin diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 55235383..30c4f8a8 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -37,6 +37,14 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule - `frontend (typecheck + lint + build + test)` - `meta (commit hygiene + link audit)` - `secret scan (leakwatch)` + - `backend integration (Testcontainers)` — **activated in Phase 02a Packet 6** + with the four-role provisioning suite, one packet earlier than planned: + Packet 6 ships the first Docker-bound test and is therefore the packet that + has to split them. The job carries no `vars.ENABLE_*` gate and no placeholder + step, so it already runs on every pull request. **Adding it to the live + branch-protection rule is the one remaining edit**, and it is a repository + setting rather than a file in this repo — until it is made, the job runs and + gates nothing. - Deferred checks. Each is gated on a repository variable (`vars.ENABLE_*`, unset by default — a constant `if: false` is rejected by actionlint). Activating one is **four edits, in the same pull request wherever possible**: @@ -45,8 +53,6 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule `(deferred …)` suffix, and add the new name both to this list and to the live branch-protection setting. Setting the variable alone leaves a job that runs but gates nothing. - - `backend integration (Testcontainers — deferred)` — Phase 02a **Packet 7**, - with the first cross-tenant isolation test. - `openapi diff (deferred to Phase 02d)` — **Phase 02d**, with the first real `/api/v1/*` read endpoints. - `lighthouse budget (deferred to Phase 02d)` — **Phase 02d**, with the first diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f627467e..4987c33b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,20 +12,23 @@ # - frontend : pnpm install + typecheck + lint + build + Vitest # - meta : `make lint`-style format verification # -# Deferred to later phases. Each is scaffolded behind a repository variable +# Active since Phase 02a Packet 6: +# - backend-integration : Testcontainers needs a real Docker socket inside the +# runner, which `ubuntu-latest` has natively. Brought forward from Packet 7, +# because Packet 6 ships the first Docker-bound test — the four-role +# provisioning suite — and is therefore the packet that has to split them. +# The HTTP tests in LearnStack.Tests.Integration stay in the `backend` job: +# they use WebApplicationFactory and need no Docker. The split is by +# [Trait("Requires","Docker")] and the two jobs' filters are exact +# complements, so every test runs in exactly one of them. Making it a +# REQUIRED check is a repository setting — see .github/CONTRIBUTING.md +# § Branch protection. +# +# Still deferred to later phases. Each is scaffolded behind a repository variable # (`vars.ENABLE_*`), unset by default, so activation is one variable plus the # real steps. A constant `if: false` would be simpler but actionlint rejects it # ([if-cond] constant expression). Activation is never *only* the variable — # see .github/CONTRIBUTING.md § Branch protection for the three edits: -# - backend-integration : Testcontainers needs a real Docker socket inside -# the runner — works on `ubuntu-latest` natively. Activates in Phase 02a -# Packet 6 — brought forward from Packet 7, because Packet 6 ships the -# first Docker-bound test (the four-role provisioning suite) and is -# therefore the packet that has to split them. The HTTP tests in -# LearnStack.Tests.Integration stay in the `backend` job: they use -# WebApplicationFactory and need no Docker. The split is by -# [Trait("Requires","Docker")] and the two jobs' filters are exact -# complements, so every test runs in exactly one of them. # - openapi-diff : oasdiff against the prior `main` spec. Activates # in Phase 02d, which ships the first real `/api/v1/*` read endpoints and # retires `/healthz` as the only documented surface. @@ -126,7 +129,7 @@ jobs: path: artifacts/backend-tests if-no-files-found: warn - # ─── Backend integration (deferred — Testcontainers harness lights up Phase 02a) ─ + # ─── Backend integration (Testcontainers — active since Phase 02a Packet 6) ──── backend-integration: name: backend integration (Testcontainers) runs-on: ubuntu-latest @@ -140,16 +143,26 @@ jobs: # run in nowhere, which is why the value is a constant # (LearnStack.Tests.Integration.Database.RequiresDocker) rather than a string # repeated at each call site. + timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false - - uses: actions/setup-dotnet@v4 + # Same shape as the `backend` job: the SDK version comes from the + # workflow-level DOTNET_SDK_VERSION, not from a global.json — the only + # global.json in this repository is backend/global.json, and pointing + # setup-dotnet at a repo-root path that does not exist fails the job + # before a single test runs. + - name: Set up .NET SDK + uses: actions/setup-dotnet@v4 with: - global-json-file: global.json + dotnet-version: ${{ env.DOTNET_SDK_VERSION }} - name: Restore working-directory: backend - run: dotnet restore LearnStack.slnx --locked-mode + run: dotnet restore LearnStack.slnx - name: Build working-directory: backend diff --git a/Makefile b/Makefile index 41c05692..3a26a3e4 100644 --- a/Makefile +++ b/Makefile @@ -130,17 +130,41 @@ migrate: ## Apply every module's EF migrations as `learnstack_migration` (the ON @# learnstack_app role, which holds USAGE but not CREATE on schema public and @# fails with `permission denied for schema public`. The tempting fix for that @# error (granting it CREATE) is the ownership mistake above. - @set -a; test -f .env && . ./.env; set +a; \ - if [ -z "$${ConnectionStrings__Migration:-}" ]; then \ - echo "ConnectionStrings__Migration is not set."; \ - echo "It arrives with the four-role model in Phase 02a Packet 6: copy the"; \ - echo "'four database roles' and 'four connection strings' blocks out of"; \ - echo ".env.example into your .env and re-run. A .env written before that"; \ - echo "packet has neither."; \ - exit 1; \ + @# `-e` does NOT abort on a failure inside a for-loop body that is part of a + @# compound list: measured, both iterations ran after `false` and the recipe + @# still exited 0. A migration target that reports success after every + @# migration failed is worse than one that does not exist, so the loop carries + @# the status in `failed` and the recipe ends on it. + @# + @# `.env` is read one key at a time, NOT shell-sourced. A connection string + @# contains semicolons, and `. ./.env` on an unquoted row parses them as + @# statement separators: measured, `ConnectionStrings__Migration` arrived as + @# `Host=localhost` and `Port`, `Database`, `Username`, `Password` leaked into + @# the environment as bare variables. `.env.example` quotes its values, but a + @# developer's `.env` predates that, so this reads the value rather than + @# trusting the file's quoting. + @migration_cs="$${ConnectionStrings__Migration:-}"; \ + if [ -z "$$migration_cs" ] && [ -f .env ]; then \ + migration_cs=$$(sed -n "s/^ConnectionStrings__Migration=//p" .env | tail -1 | sed "s/^['\"]//; s/['\"]$$//"); \ fi; \ + case "$$migration_cs" in \ + *Username=learnstack_migration*) ;; \ + "") echo "ConnectionStrings__Migration is not set."; \ + echo "It arrives with the four-role model in Phase 02a Packet 6: copy the"; \ + echo "'four database roles' and 'four connection strings' blocks out of"; \ + echo ".env.example into your .env and re-run. A .env written before that"; \ + echo "packet has neither."; \ + exit 1 ;; \ + *) echo "ConnectionStrings__Migration does not name learnstack_migration:"; \ + echo " $$migration_cs"; \ + echo "Migrations must run as the role that OWNS every table. Running them as"; \ + echo "the runtime role is the arrangement FORCE ROW LEVEL SECURITY defeats."; \ + echo "If the value looks truncated at the first ';', quote it in .env."; \ + exit 1 ;; \ + esac; \ dotnet tool restore >/dev/null; \ found=0; \ + failed=0; \ for proj in backend/src/Modules/*/LearnStack.Modules.*.Infrastructure; do \ test -d "$$proj/Persistence/Migrations" || continue; \ found=1; \ @@ -148,11 +172,12 @@ migrate: ## Apply every module's EF migrations as `learnstack_migration` (the ON dotnet ef database update \ --project "$$proj" \ --startup-project backend/src/LearnStack.Api \ - --connection "$$ConnectionStrings__Migration"; \ + --connection "$$migration_cs" || failed=1; \ done; \ if [ "$$found" = "0" ]; then \ echo "No module carries Persistence/Migrations yet — the first lands with the Tenancy schema in Phase 02a Packet 6."; \ - fi + fi; \ + exit "$$failed" .PHONY: sdk sdk: ## Regenerate @learnstack/sdk types from a running API's OpenAPI document. @@ -166,15 +191,17 @@ sdk: ## Regenerate @learnstack/sdk types from a running API's OpenAPI document. test: test-backend test-frontend ## Run all test suites (backend + frontend). .PHONY: test-backend -test-backend: ## `dotnet test` (unit + architecture + contract + HTTP integration — same set as CI). +test-backend: ## `dotnet test` — every suite, INCLUDING the Docker-bound ones CI splits across two jobs. (cd backend && dotnet test LearnStack.slnx --nologo) .PHONY: test-integration test-integration: ## Just the LearnStack.Tests.Integration assembly (a subset of test-backend). - @# Today this assembly holds only WebApplicationFactory HTTP tests and needs - @# no Docker, so `make test-backend` already covers it. The target stays as - @# the fast inner loop while working on that assembly, and becomes the - @# Docker-bound entry point in Packet 7 when Testcontainers tests land. + @# From Phase 02a Packet 6 this assembly holds BOTH kinds: WebApplicationFactory + @# HTTP tests that need no Docker, and Testcontainers tests carrying + @# [Trait("Requires","Docker")]. CI splits them across two jobs by that trait; + @# this target and `make test-backend` run both, because a developer's machine + @# has the daemon CI has to arrange for. Narrow with + @# `--filter "Requires!=Docker"` when Docker is down. (cd backend && dotnet test tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj --nologo) .PHONY: test-frontend diff --git a/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs index ad2804f9..a1d5145e 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs @@ -39,7 +39,8 @@ public async Task EachRoleExistsWithItsDeclaredBypassPosture(string role, bool e { await using var connection = await PostgresFixture.OpenAsync(_postgres.MigrationConnectionString); await using var command = new NpgsqlCommand( - "SELECT rolbypassrls, rolcanlogin FROM pg_roles WHERE rolname = @role", (NpgsqlConnection)connection); + "SELECT rolbypassrls, rolcanlogin, rolsuper, rolcreatedb, rolcreaterole FROM pg_roles WHERE rolname = @role", + (NpgsqlConnection)connection); command.Parameters.AddWithValue("role", role); await using var reader = await command.ExecuteReaderAsync(); @@ -47,6 +48,13 @@ public async Task EachRoleExistsWithItsDeclaredBypassPosture(string role, bool e (await reader.ReadAsync()).Should().BeTrue($"{role} must exist"); reader.GetBoolean(0).Should().Be(expectedBypass); reader.GetBoolean(1).Should().BeTrue($"{role} logs in for itself — per-role settings are applied at login and do not follow SET ROLE"); + + // rolbypassrls alone is not the question. A SUPERUSER bypasses row security + // whatever that column says, so asserting only the attribute would let the + // whole model be defeated by one CREATE ROLE … SUPERUSER and still pass. + reader.GetBoolean(2).Should().BeFalse($"{role} must not be a superuser: a superuser bypasses RLS regardless of rolbypassrls"); + reader.GetBoolean(3).Should().BeFalse($"{role} has no reason to create databases"); + reader.GetBoolean(4).Should().BeFalse($"{role} creating roles could grant itself the bypass"); } [Fact] @@ -58,17 +66,18 @@ public async Task TheApplicationRoleIsNotAMemberOfTheBypassRoles() // tenant's request. EnterPlatformAdminScope uses a second credentialed // connection instead. await using var connection = await PostgresFixture.OpenAsync(_postgres.MigrationConnectionString); + // pg_has_role, not pg_auth_members: membership is TRANSITIVE, and a chain + // through a third role would satisfy a direct-membership query while still + // handing learnstack_app the bypass. The catalogue join tests the shape of + // the graph; this tests the question actually being asked. await using var command = new NpgsqlCommand( """ - SELECT count(*) - FROM pg_auth_members m - JOIN pg_roles member ON member.oid = m.member - JOIN pg_roles granted ON granted.oid = m.roleid - WHERE member.rolname = 'learnstack_app' - AND granted.rolname IN ('learnstack_platform', 'learnstack_outbox_admin') + SELECT pg_has_role('learnstack_app', 'learnstack_platform', 'USAGE') + OR pg_has_role('learnstack_app', 'learnstack_outbox_admin', 'USAGE') + OR pg_has_role('learnstack_app', 'learnstack_migration', 'USAGE') """, (NpgsqlConnection)connection); - (await command.ExecuteScalarAsync()).Should().Be(0L); + (await command.ExecuteScalarAsync()).Should().Be(false); } [Fact] @@ -111,6 +120,24 @@ public async Task TheMigrationRoleOwnsWhatItCreates_AndOwnershipGrantsNoBypass() (NpgsqlConnection)migration); (await owner.ExecuteScalarAsync()).Should().Be("learnstack_migration"); + + // The second half of the name, which the first version of this test did not + // assert. FORCE ROW LEVEL SECURITY is what makes ownership grant no bypass, + // and the owner being NOBYPASSRLS is what makes FORCE meaningful — so the + // owner is subjected to its own policies like anyone else. + await using var force = new NpgsqlCommand( + """ + ALTER TABLE owner_probe ENABLE ROW LEVEL SECURITY; + ALTER TABLE owner_probe FORCE ROW LEVEL SECURITY; + CREATE POLICY owner_probe_never ON owner_probe USING (false) WITH CHECK (false); + INSERT INTO owner_probe VALUES (1); + """, (NpgsqlConnection)migration); + + var act = async () => await force.ExecuteNonQueryAsync(); + + (await act.Should().ThrowAsync()) + .Which.SqlState.Should().Be(PostgresErrorCodes.InsufficientPrivilege, + "the owner is refused by its own policy — that is what FORCE buys"); } [Fact] @@ -156,6 +183,31 @@ public async Task ABypassRoleWithNoGrantIsStillRefused() .Which.SqlState.Should().Be(PostgresErrorCodes.InsufficientPrivilege); } + [Fact] + public async Task EveryRoleCredentialActuallyLogsIn() + { + // All four, including learnstack_outbox_admin, whose connection string no + // other case opens. A password the script never set, or set to something + // else, is invisible until the dispatcher tries to start — in Phase 02b, + // far from here. + foreach (var connectionString in new[] + { + _postgres.MigrationConnectionString, + _postgres.AppConnectionString, + _postgres.PlatformConnectionString, + _postgres.OutboxConnectionString, + }) + { + await using var connection = await PostgresFixture.OpenAsync(connectionString); + await using var who = new NpgsqlCommand("SELECT current_user", (NpgsqlConnection)connection); + + var actual = (string?)await who.ExecuteScalarAsync(); + + connectionString.Should().Contain($"Username={actual}", + "the role that authenticated must be the role the connection string named"); + } + } + [Fact] public async Task TheRolesScriptIsIdempotent() { diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs index b5a7baa4..1a10bcb5 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs @@ -16,13 +16,18 @@ namespace LearnStack.Tests.Integration.Database; /// real Docker socket and runs in backend-integration. /// /// -/// The two filters must stay exact complements. `backend` runs +/// The two filters are exact complements. `backend` runs /// --filter "Requires!=Docker" and `backend-integration` runs -/// --filter "Requires=Docker", so every test runs exactly once. A -/// mistyped trait value belongs to neither set and would run nowhere — green in -/// both jobs, executed in neither, which is the failure mode the `backend` job's -/// own comment warns about after a filter once hid a broken route convention. -/// Hence one constant rather than a string at each call site. +/// --filter "Requires=Docker", so every test runs exactly once — +/// measured: 11 + 135 against 146 total. +/// +/// +/// The value is a constant because a mistyped one lands in the wrong job. +/// Measured: Requires!=Docker matches every test with no Requires +/// trait at all, so Requires=Dockerr would satisfy it and run in +/// backend, where there is no Docker daemon — a loud failure rather than a +/// silent skip, but a failure in the job that cannot fix it. One constant removes +/// the class. /// /// internal static class RequiresDocker @@ -156,9 +161,19 @@ private string For(string role, string password) => /// bin/Debug/net10.0. /// /// +/// /// A relative path resolves against that directory, where nothing exists — the /// query silently yields nothing and the assertion over it passes. Walking up to -/// the repository root is the only form that fails loudly when it is wrong. +/// a marker is the only form that fails loudly when it is wrong. +/// +/// +/// LearnStack.Tests.Architecture has its own RepositoryPaths doing +/// the same walk. This is a deliberate duplicate rather than a shared helper: the +/// two assemblies reference nothing in common, and a shared test-utility project +/// referenced by both would be a fourth test project to justify against +/// Standards 06's three. +/// Both markers are files that only exist at the root. +/// /// internal static class RepositoryPath { @@ -169,13 +184,21 @@ private static string Root() { var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "CLAUDE.md"))) + // Two markers, because either alone is reachable in a layout that is not + // the repository: a published output can carry a stray CLAUDE.md, and a + // git worktree of a submodule can carry a .git file. Both together, at the + // same level, are the root. + while (directory is not null + && !(File.Exists(Path.Combine(directory.FullName, "CLAUDE.md")) + && Directory.Exists(Path.Combine(directory.FullName, "infra", "compose")))) { directory = directory.Parent; } return directory?.FullName ?? throw new InvalidOperationException( - $"Repository root not found above {AppContext.BaseDirectory}."); + $"Repository root not found above {AppContext.BaseDirectory}. " + + "This fixture reads infra/compose/postgres-init/02-create-roles.sql " + + "from the working tree; it cannot run from a published layout."); } } diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index 624cb265..7125ac38 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -792,10 +792,12 @@ reads `platform_host_to_tenant` in order to *determine* the tenant. Three classe Packet 6 also ships `infra/compose/postgres-init/02-create-roles.sql` and splits the development connection strings — `learnstack_migration` for `dotnet ef`, `learnstack_app` -for the API, plus the two bypass roles' own credentials. Until it lands, -`infra/compose/dev.yml` runs everything as one `POSTGRES_USER` superuser, which owns -every table and therefore bypasses every policy: the isolation layer is inert in local -development and every isolation test would pass against it. The script also grants +for the API, plus the two bypass roles' own credentials. Before it landed, `infra/compose/dev.yml` ran everything as one `POSTGRES_USER` +superuser, which owns every table and therefore bypasses every policy: the isolation +layer was inert in local development and every isolation test would have passed +against it. **A `postgres-data` volume created before this packet still has no +roles** — init scripts do not re-run — and +[`infra/compose/README.md`](../../infra/compose/README.md) carries the recovery. The script also grants `CREATE ON SCHEMA public` to `learnstack_migration` — since PostgreSQL 15 the public schema no longer grants it to `PUBLIC`, so without it the first migration fails with `permission denied for schema public`, and the tempting fix (make the role a superuser) diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 8ebcff41..0853ef84 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -532,10 +532,20 @@ it stood failed on its first statement with `syntax error at or near ":"`. `\getenv` reads each one from the container environment. [Infrastructure Standards § Local Infrastructure](12-infrastructure.md) requires every credential to be `${VAR:-fallback}` in compose with a matching row in -`.env.example`; **the four rows and the four compose entries do not exist yet** -and land in the same Packet 6 change as the script itself, in `.env.example` and -in the `postgres` service's `environment:` block of both `dev.yml` and -`e2e.yml`. Measured, it works under the entrypoint's `ON_ERROR_STOP=1`, and +`.env.example`, and Packet 6 shipped both: the four `LEARNSTACK_*_PW` rows in +`.env.example` and the matching entries on the `postgres` service in `dev.yml`. +`e2e.yml` needs none — it overlays `dev.yml` and overrides only `volumes:`, so +the `environment:` block is inherited. + +**The shipped script is `infra/compose/postgres-init/02-create-roles.sql` and it +is idempotent**, which the fence below is not: `CREATE ROLE` has no +`IF NOT EXISTS`, so each statement is generated by +`SELECT format(…) WHERE NOT EXISTS (SELECT FROM pg_roles …) \gexec`. The fence +states the *model* — which roles, which attributes, which grants; the script is +the executable form and `TheRolesScriptIsIdempotent` proves the re-run is a +no-op. It also revokes `CONNECT, TEMPORARY` from `PUBLIC` before granting, which +the fence omits and without which the grants add nothing: measured, +`learnstack_app` could otherwise connect to every database in the cluster. Measured, it works under the entrypoint's `ON_ERROR_STOP=1`, and an **unset** variable leaves the placeholder unbound and aborts init rather than creating a passwordless superuser-adjacent role — failing loud is the point. diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index feb3a97d..6fa25d61 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -36,7 +36,7 @@ We invest most at **unit + integration**. Architecture tests are zero-flake. E2E | Type | Project | Tool | |------|---------|------| | Unit | `LearnStack.Tests.Unit` | xUnit, FluentAssertions | -| Integration | `LearnStack.Tests.Integration` | xUnit + `WebApplicationFactory` (Docker-free host tests) and, from Packet 7, Testcontainers + Respawn | +| Integration | `LearnStack.Tests.Integration` | xUnit + `WebApplicationFactory` (Docker-free host tests) and, from Packet 6, Testcontainers marked `[Trait("Requires","Docker")]` — CI runs the two halves in separate jobs by that trait | | Architecture | `LearnStack.Tests.Architecture` | NetArchTest / ArchUnitNET | | API contract | `LearnStack.Tests.Contract` | OpenAPI snapshot, Pact-style consumer tests | | End-to-end | none yet | Playwright, per § End-to-End Tests below. No project exists; the owning phase is named there | diff --git a/infra/compose/README.md b/infra/compose/README.md index eabd2ada..923bf675 100644 --- a/infra/compose/README.md +++ b/infra/compose/README.md @@ -37,10 +37,30 @@ Two realms imported on first boot from `infra/keycloak/realms/`: See [../keycloak/README.md](../keycloak/README.md) for the realm-isolation invariant, re-seed procedure, and the Phase 02b/03 wiring notes. -The Postgres init script at `postgres-init/01-create-keycloak-db.sql` creates -the `keycloak` database on the first start of the `postgres-data` volume. -Re-seeding the realm structure requires either `down -v` (wipes all volumes) -or a manual `DROP DATABASE keycloak; CREATE DATABASE keycloak OWNER learnstack;`. +Two Postgres init scripts run, in name order, **only on the first start of a +fresh `postgres-data` volume**: + +| Script | Creates | +|---|---| +| `postgres-init/01-create-keycloak-db.sql` | the `keycloak` database Keycloak stores its realm state in | +| `postgres-init/02-create-roles.sql` | the four database roles of [ADR-0003 Amendment 3](../../docs/decisions/0003-tenant-isolation-defense-in-depth.md) — `learnstack_migration`, `learnstack_app`, `learnstack_platform`, `learnstack_outbox_admin` — reading their passwords from `LEARNSTACK_*_PW` in the environment | + +**A volume created before Phase 02a Packet 6 has no roles**, and nothing will +tell you: init scripts do not re-run, `make dev` reports healthy, and the first +`make migrate` fails with `password authentication failed for user +"learnstack_migration"`. Either run `make clean` (destructive — drops every +volume) and `make dev`, or apply the script by hand: + +```bash +docker compose --env-file .env -f infra/compose/dev.yml exec -T postgres psql -v ON_ERROR_STOP=1 -U learnstack -d learnstack < infra/compose/postgres-init/02-create-roles.sql +``` + +It is idempotent, so re-running it against a cluster that already has the roles +is a no-op rather than an error. + +Re-seeding the Keycloak realm structure requires either `down -v` (wipes all +volumes) or a manual +`DROP DATABASE keycloak; CREATE DATABASE keycloak OWNER learnstack;`. ### Live media (Phase 01 packet 5) diff --git a/infra/compose/postgres-init/02-create-roles.sql b/infra/compose/postgres-init/02-create-roles.sql index b1198dfc..a38a435c 100644 --- a/infra/compose/postgres-init/02-create-roles.sql +++ b/infra/compose/postgres-init/02-create-roles.sql @@ -58,11 +58,26 @@ WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'learnstack_outbox_admin' -- credentialed connection instead (Standards 05 § How EnterPlatformAdminScope -- reaches learnstack_platform). --- :"db" quotes as an identifier. The database name is POSTGRES_DB, which .env --- may override, so the literal `learnstack` would grant CONNECT on a database --- that need not exist. Note this grants nothing on the `keycloak` database that --- 01 creates — Keycloak keeps its own role, and these four have no business --- there. +-- PUBLIC holds CONNECT and TEMPORARY on every database by default, so the grant +-- below adds nothing until that default is removed. Revoke first, and the four +-- explicit grants become the whole of the reach INTO THIS DATABASE — measured: +-- afterwards `pg_database.datacl` carries no `=Tc` entry for PUBLIC, only the +-- four `c` grants and the owner's. +-- +-- Scope is exactly one database, and that is worth saying because it is easy to +-- read as more. Measured: `learnstack_app` can still connect to `keycloak` and +-- `postgres`, because PUBLIC's default there is untouched. That is accepted. The +-- `keycloak` database is a dev-only convenience — 01-create-keycloak-db.sql +-- creates it and Keycloak connects to it as POSTGRES_USER, and production +-- isolates Keycloak in its own cluster entirely per Standards 12 § Database +-- Operations — so it is not a boundary anything relies on. The boundary that +-- matters is inside `:"db"`, and it is the policies plus the grant matrix, not +-- the ability to open a connection. +-- +-- :"db" quotes as an identifier. The database name is POSTGRES_DB, which .env may +-- override, so the literal `learnstack` would name a database that need not +-- exist. +REVOKE CONNECT, TEMPORARY ON DATABASE :"db" FROM PUBLIC; GRANT CONNECT ON DATABASE :"db" TO learnstack_migration, learnstack_app, learnstack_platform, learnstack_outbox_admin; From e14f577731ca9874de171d249992a6d1e1c42d6b Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 10:44:26 +0300 Subject: [PATCH 11/35] fix(infra): close the Step 3 Sonnet round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight agents, four lenses. One blocker, seven major, four minor — and the blocker was introduced by the previous fix commit, in the one target whose whole purpose is keeping a credential in one place. **The wrong-role guard printed the password.** The error path I added echoed the whole connection string so a developer could see what was wrong; reproduced with a seeded `.env`, it prints `Password=SUPER-SECRET-PW` verbatim to stdout, and CONTRIBUTING already anticipates this target running in CI. It prints a redacted form now — `Password=***` — and the role it actually found. Two more in the same guard, both reproduced: - The role was matched as an unanchored substring, so `Username=learnstack_migration_readonly` passed and would have run migrations as it. The token is split on `;` and compared exactly. - A CRLF-terminated `.env` row defeated the quote-stripping sed: `od -c` showed the extracted value ending `Password=x'` followed by a raw `\r`, and the guard passed it. `tr -d '\r'` first. **A test was missing for the fix that motivated it.** The previous commit added `REVOKE CONNECT, TEMPORARY … FROM PUBLIC` and argued for it from a measurement — and nothing asserted it. Proved by removing the line: all twelve cases stayed green. There is a case now, over `pg_database.datacl`, and the same mutation kills it and only it. **And one of my assertions could never fail.** `connectionString.Should().Contain($"Username={current_user}")` reads like a check that the credential binds to the role it names, but under password auth a successful `OpenAsync` already guarantees it — true a priori everywhere it is reached. It compares against the expected role name now, which is independent of the connection that proved it. `--logger "trx;LogFileName=…"` made all four projects write the same path in the same results directory. Measured: one 874 KB file where four should be, so three assemblies' outcomes were silently overwritten and the uploaded artifact showed only the last to finish. Both jobs use `--logger trx` and let it name per assembly — four files. Corpus: 06-testing's prose still put these tests in Packet 7 two paragraphs under the table row saying Packet 6; add-integration-test still said the fixture does not exist, in both its body and its frontmatter; a comment of mine cited Standards 12 for a Keycloak cluster-isolation claim that section does not make; and PostgresFixture carried a measured test count that went stale in the same commit that added a test — it states the property now and no numbers. 766 tests green across four suites. Partition still exact: 13 + 135 = 148. Format clean, link audit clean, ci.yml parses. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-integration-test/SKILL.md | 25 ++++--- .github/workflows/ci.yml | 8 ++- Makefile | 68 +++++++++++-------- .../Database/DatabaseRoleTests.cs | 58 +++++++++++++--- .../Database/PostgresFixture.cs | 5 +- docs/standards/06-testing.md | 10 +-- .../compose/postgres-init/02-create-roles.sql | 6 +- 7 files changed, 121 insertions(+), 59 deletions(-) diff --git a/.claude/skills/add-integration-test/SKILL.md b/.claude/skills/add-integration-test/SKILL.md index a25f0c4e..7fcc22dd 100644 --- a/.claude/skills/add-integration-test/SKILL.md +++ b/.claude/skills/add-integration-test/SKILL.md @@ -5,8 +5,10 @@ description: > `backend/tests/LearnStack.Tests.Integration` that exercises a real Postgres — connected as `learnstack_app`, never as the owner — and asserts behaviour under tenant + organization context. No Valkey and no Kafka: nothing the backend runs - calls them. The fixture arrives in Phase 02a Packet 6; the isolation suite and - CI's `backend-integration` job activate in Packet 7. USE FOR: cross-tenant / cross-org isolation tests (mandatory for every + calls them. The Postgres fixture and CI's `backend-integration` job both + arrived in Phase 02a Packet 6; the tenant-isolation suite and the seeded + tenants follow in Packet 7. Docker-bound cases carry + `[Trait("Requires","Docker")]`, which is how CI routes them. USE FOR: cross-tenant / cross-org isolation tests (mandatory for every new `[TenantOwned]` / `[OrganizationScoped]` entity), outbox → consumer round trips, audit-pipeline assertions, RLS-effective-isolation tests. DO NOT USE FOR: pure unit tests (use `LearnStack.Tests.Unit`), architecture tests (use @@ -54,14 +56,17 @@ architecture test) plus any other invariant the change touches. See ### Step 1: Reuse the fixture -> **There is no `TestFixture` yet.** `LearnStack.Tests.Integration` today holds -> HTTP tests over `WebApplicationFactory` and touches no container; the -> Testcontainers packages are referenced and unused. Phase 02a **Packet 6** -> introduces the Postgres fixture and **Packet 7** the isolation suite, which is -> also when CI's `backend-integration` job activates. Until then, treat the shape -> below as the target and write HTTP-only tests against the existing factory. - -The fixture Packet 6 introduces: +> **The Postgres fixture exists** — `LearnStack.Tests.Integration.Database.PostgresFixture`, +> shipped by Phase 02a Packet 6 along with CI's `backend-integration` job. What +> does **not** exist yet is the tenant-isolation suite or any seeded tenant: the +> fixture provisions the four roles and hands out four connection strings, and +> Packet 7 adds the schema, the policies and the seed. The `AsTenant(...)` helper +> below is Packet 7's shape, not today's. +> +> Anything using it carries `[Trait(RequiresDocker.Key, RequiresDocker.Value)]`, +> which is how CI routes it to `backend-integration` rather than `backend`. + +What the fixture does today: - Spins **Postgres only** via Testcontainers. Not Valkey, not Kafka — nothing the backend runs today calls either, and both sit behind the `gated` compose profile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4987c33b..0712aa4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,9 +118,13 @@ jobs: dotnet test LearnStack.slnx \ --no-restore --no-build --configuration Release \ --filter "Requires!=Docker" \ - --logger "trx;LogFileName=test-results.trx" \ + --logger trx \ --results-directory ../artifacts/backend-tests + # `--logger trx` without a LogFileName, deliberately. A fixed name makes all + # four projects write the SAME path in the SAME results directory: measured, + # one 874 KB file where four should be, so three assemblies' outcomes were + # silently overwritten and the artifact showed only the last to finish. - name: Upload test results if: always() uses: actions/upload-artifact@v4 @@ -176,7 +180,7 @@ jobs: dotnet test LearnStack.slnx \ --no-restore --no-build --configuration Release \ --filter "Requires=Docker" \ - --logger "trx;LogFileName=integration-results.trx" \ + --logger trx \ --results-directory ../artifacts/backend-integration - name: Upload test results diff --git a/Makefile b/Makefile index 3a26a3e4..a2d33053 100644 --- a/Makefile +++ b/Makefile @@ -130,38 +130,50 @@ migrate: ## Apply every module's EF migrations as `learnstack_migration` (the ON @# learnstack_app role, which holds USAGE but not CREATE on schema public and @# fails with `permission denied for schema public`. The tempting fix for that @# error (granting it CREATE) is the ownership mistake above. - @# `-e` does NOT abort on a failure inside a for-loop body that is part of a - @# compound list: measured, both iterations ran after `false` and the recipe - @# still exited 0. A migration target that reports success after every - @# migration failed is worse than one that does not exist, so the loop carries - @# the status in `failed` and the recipe ends on it. + @# Four things this recipe does that a naive version does not, each because the + @# naive version was measured doing the wrong thing: @# - @# `.env` is read one key at a time, NOT shell-sourced. A connection string - @# contains semicolons, and `. ./.env` on an unquoted row parses them as - @# statement separators: measured, `ConnectionStrings__Migration` arrived as - @# `Host=localhost` and `Port`, `Database`, `Username`, `Password` leaked into - @# the environment as bare variables. `.env.example` quotes its values, but a - @# developer's `.env` predates that, so this reads the value rather than - @# trusting the file's quoting. + @# 1. `.env` is read one key at a time, NOT shell-sourced. A connection string + @# contains semicolons, and `. ./.env` on an unquoted row parses them as + @# statement separators: `ConnectionStrings__Migration` arrived as + @# `Host=localhost`, and `Port`, `Database`, `Username`, `Password` leaked + @# into the environment as bare variables. `tr -d '\r'` as well, because a + @# CRLF row otherwise leaves the closing quote and the carriage return + @# inside the credential. + @# 2. The role is compared EXACTLY. An unanchored `*Username=learnstack_migration*` + @# accepted `learnstack_migration_readonly` and would have run migrations as + @# it. Splitting on `;` and comparing the whole token has no neighbourhood. + @# 3. The error path prints a REDACTED string. An earlier version echoed the + @# whole value, password included — in the one target whose entire purpose + @# is keeping that credential in one place. + @# 4. The loop carries its own status. `-e` does not abort on a failure inside + @# a for-loop body that is part of a compound list: both iterations ran + @# after `false` and the recipe still exited 0, so a migration target + @# reported success after every migration failed. @migration_cs="$${ConnectionStrings__Migration:-}"; \ if [ -z "$$migration_cs" ] && [ -f .env ]; then \ - migration_cs=$$(sed -n "s/^ConnectionStrings__Migration=//p" .env | tail -1 | sed "s/^['\"]//; s/['\"]$$//"); \ + migration_cs=$$(sed -n "s/^ConnectionStrings__Migration=//p" .env \ + | tail -1 | tr -d "\r" | sed "s/^['\"]//; s/['\"]$$//"); \ + fi; \ + role=$$(printf '%s' "$$migration_cs" | tr ';' '\n' \ + | sed -n 's/^[[:space:]]*[Uu][Ss][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]*=[[:space:]]*//p' | head -1); \ + redacted=$$(printf '%s' "$$migration_cs" | sed "s/[Pp]assword[[:space:]]*=[^;]*/Password=***/g"); \ + if [ -z "$$migration_cs" ]; then \ + echo "ConnectionStrings__Migration is not set."; \ + echo "It arrives with the four-role model in Phase 02a Packet 6: copy the"; \ + echo "'four database roles' and 'four connection strings' blocks out of"; \ + echo ".env.example into your .env and re-run. A .env written before that"; \ + echo "packet has neither."; \ + exit 1; \ + fi; \ + if [ "$$role" != "learnstack_migration" ]; then \ + echo "ConnectionStrings__Migration names Username='$$role', not learnstack_migration:"; \ + echo " $$redacted"; \ + echo "Migrations must run as the role that OWNS every table. Running them as"; \ + echo "the runtime role is the arrangement FORCE ROW LEVEL SECURITY defeats."; \ + echo "If the value looks truncated at the first ';', quote it in .env."; \ + exit 1; \ fi; \ - case "$$migration_cs" in \ - *Username=learnstack_migration*) ;; \ - "") echo "ConnectionStrings__Migration is not set."; \ - echo "It arrives with the four-role model in Phase 02a Packet 6: copy the"; \ - echo "'four database roles' and 'four connection strings' blocks out of"; \ - echo ".env.example into your .env and re-run. A .env written before that"; \ - echo "packet has neither."; \ - exit 1 ;; \ - *) echo "ConnectionStrings__Migration does not name learnstack_migration:"; \ - echo " $$migration_cs"; \ - echo "Migrations must run as the role that OWNS every table. Running them as"; \ - echo "the runtime role is the arrangement FORCE ROW LEVEL SECURITY defeats."; \ - echo "If the value looks truncated at the first ';', quote it in .env."; \ - exit 1 ;; \ - esac; \ dotnet tool restore >/dev/null; \ found=0; \ failed=0; \ diff --git a/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs index a1d5145e..559c5f1b 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs @@ -190,22 +190,60 @@ public async Task EveryRoleCredentialActuallyLogsIn() // other case opens. A password the script never set, or set to something // else, is invisible until the dispatcher tries to start — in Phase 02b, // far from here. - foreach (var connectionString in new[] - { - _postgres.MigrationConnectionString, - _postgres.AppConnectionString, - _postgres.PlatformConnectionString, - _postgres.OutboxConnectionString, - }) + // Compared against the EXPECTED role, not against one derived from the same + // connection that just proved it. `connectionString.Contains($"Username={current_user}")` + // reads like an assertion and is not: under password auth a successful + // OpenAsync already guarantees current_user equals the string's Username, so + // it is true a priori everywhere it is reached and can never be the thing + // that fails. + (string Expected, string ConnectionString)[] roles = + [ + ("learnstack_migration", _postgres.MigrationConnectionString), + ("learnstack_app", _postgres.AppConnectionString), + ("learnstack_platform", _postgres.PlatformConnectionString), + ("learnstack_outbox_admin", _postgres.OutboxConnectionString), + ]; + + foreach (var (expected, connectionString) in roles) { await using var connection = await PostgresFixture.OpenAsync(connectionString); await using var who = new NpgsqlCommand("SELECT current_user", (NpgsqlConnection)connection); - var actual = (string?)await who.ExecuteScalarAsync(); + (await who.ExecuteScalarAsync()).Should().Be(expected, + "the fixture's {0} connection string must authenticate as {0}", expected); + } + } - connectionString.Should().Contain($"Username={actual}", - "the role that authenticated must be the role the connection string named"); + [Fact] + public async Task PublicHoldsNoConnectPrivilegeOnTheDatabase() + { + // PUBLIC holds CONNECT and TEMPORARY on every database by default, so the + // script's four explicit grants add nothing until that default is revoked. + // Asserted because nothing else in this suite would notice: removing the + // REVOKE from the script leaves all the other cases green, which was + // measured — the grants and the roles are unaffected, only the reach is. + // + // pg_database.datacl renders PUBLIC's entry with an empty grantee, i.e. a + // leading "=" — `=Tc/owner`. Its absence is the property. + await using var connection = await PostgresFixture.OpenAsync(_postgres.MigrationConnectionString); + await using var command = new NpgsqlCommand( + "SELECT unnest(datacl)::text FROM pg_database WHERE datname = current_database()", + (NpgsqlConnection)connection); + + var entries = new List(); + await using (var reader = await command.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + entries.Add(reader.GetString(0)); + } } + + entries.Should().NotBeEmpty("an ACL of NULL means PostgreSQL's permissive default is still in force"); + entries.Should().NotContain(e => e.StartsWith('='), + "PUBLIC must hold no privilege on this database; the four explicit grants are the whole of the reach"); + entries.Should().Contain(e => e.StartsWith("learnstack_app="), + "and learnstack_app must still hold the CONNECT it was granted"); } [Fact] diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs index 1a10bcb5..c03252d0 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs @@ -18,8 +18,9 @@ namespace LearnStack.Tests.Integration.Database; /// /// The two filters are exact complements. `backend` runs /// --filter "Requires!=Docker" and `backend-integration` runs -/// --filter "Requires=Docker", so every test runs exactly once — -/// measured: 11 + 135 against 146 total. +/// --filter "Requires=Docker", so every test in the assembly runs in +/// exactly one of them. No count is written here on purpose: one was, and it +/// went stale in the same commit that added a test. /// /// /// The value is a constant because a mistyped one lands in the wrong job. diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index 6fa25d61..b3cdf576 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -62,10 +62,12 @@ belongs to is decided by what it needs, not by what it is about: Valkey and not SeaweedFS: nothing the backend runs calls either, and both sit behind the gated compose profile ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md)). Everything that is a property of the schema lives here, and **every tenant-isolation invariant** - does. The schema itself lands in Packet 6; these arrive with the **isolation suite** - in Packet 7 — connected as `learnstack_app`, because a test run as the owner or as a - `BYPASSRLS` role passes against inert policies — and run in the separate - `backend-integration` job. + does. The fixture and the first of these — the four-role provisioning suite — + land in **Packet 6** with the roles themselves; the tenant-isolation suite + follows in **Packet 7** with the schema's policies. Both connect as + `learnstack_app`, because a test run as the owner or as a `BYPASSRLS` role + passes against inert policies. They run in the separate `backend-integration` + job, split from the Docker-free tests by `[Trait("Requires","Docker")]`. Both: real module configuration, no mocked repositories, and coverage of the happy path and the edges. diff --git a/infra/compose/postgres-init/02-create-roles.sql b/infra/compose/postgres-init/02-create-roles.sql index a38a435c..14904867 100644 --- a/infra/compose/postgres-init/02-create-roles.sql +++ b/infra/compose/postgres-init/02-create-roles.sql @@ -68,9 +68,9 @@ WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'learnstack_outbox_admin' -- read as more. Measured: `learnstack_app` can still connect to `keycloak` and -- `postgres`, because PUBLIC's default there is untouched. That is accepted. The -- `keycloak` database is a dev-only convenience — 01-create-keycloak-db.sql --- creates it and Keycloak connects to it as POSTGRES_USER, and production --- isolates Keycloak in its own cluster entirely per Standards 12 § Database --- Operations — so it is not a boundary anything relies on. The boundary that +-- creates it and Keycloak connects to it as POSTGRES_USER (01's own comment says +-- so, and calls the shared role a dev-only convenience) — so it is not a boundary +-- anything relies on here. The boundary that -- matters is inside `:"db"`, and it is the policies plus the grant matrix, not -- the ability to open a connection. -- From 6528d495cf82ef055f8fc0f7e6ef8130c5f3f811 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 11:33:07 +0300 Subject: [PATCH 12/35] feat(tenancy): the tenancy schema, its policies and its grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packet 6 step 4 — the packet's one-way door. Eight tables, three RLS classes, per-table grants, and the first module with domain code in it. Domain: Tenant and Organization as aggregates; TenantDomain and TenantSetting as entities inside Tenant; TenantLocale and TenantFeatureFlag as composite-keyed entities with NO surrogate id and no AuditableEntity base, because their published shape is `PRIMARY KEY (tenant_id, locale)` / `(tenant_id, key)` and a second row for the same pair is not a second locale, it is a duplicate — adding an id to satisfy a base class would invent an identity the domain does not have and contradict DDL other documents already reference. Tenant.Create takes its id rather than minting one: the registry that owns the tenant assigns it, and the provisioning transaction sets app.tenant_id to that value before the INSERT, so the self-keyed policy's WITH CHECK passes. A factory that generated its own could not satisfy its own policy. Schema. Measured against a real PostgreSQL 18 with the four roles provisioned, connected as learnstack_app — which is the only connection that proves anything, since the owner or a BYPASSRLS role passes with every policy inert: no tenant context → 0 rows (fail-closed) tenant A → its own 1 + 1 tenant B, A's tenant-wide row → 0 rows <- the case the old template leaked write naming a foreign tenant_id → RLS policy violation organization_id NULL -> value → immutability trigger refused it owner on platform_host_to_tenant → 0 rows (role-qualified policies) All eight ENABLE *and* FORCE, with no exception list. One permissive policy per table; tenant_settings — the only org-scoped table here — additionally takes the two AS RESTRICTIVE guards, because USING is what selects the rows an UPDATE may target and is the ONLY gate for DELETE. platform_host_to_tenant takes the four role-qualified per-command policies, which is why the owner is denied on it: no policy applies to the owner, and under FORCE that is a denial. tenants.default_organization_id is a COMPOSITE foreign key into organizations (tenant_id, id). Single-column, tenant A could commit a permanent pointer at tenant B's organization, because referential-integrity checks run with row security bypassed. Under MATCH SIMPLE the check is skipped while the column is null, which is what makes the three-statement provisioning sequence work. tenant_settings' uniqueness is UNIQUE NULLS NOT DISTINCT, which EF cannot express, so the migration drops the index EF generated and replaces it: without it a tenant could hold unlimited duplicate tenant-wide rows for one key — the rows a single-organization tenant creates exclusively. snake_case comes from a forty-line convention rather than EFCore.NamingConventions. Measured: the only version compatible with EF Core 10 is 10.0.1 and it requires Microsoft.EntityFrameworkCore >= 10.0.1, while central package management pins 10.0.0 — taking it means bumping the ORM solution-wide, which is a larger change than a naming convention should make. A test asserts every mapped identifier is lowercase, because one PascalCase column is a column no policy mentions and no grant covers. An IDesignTimeDbContextFactory reads ConnectionStrings__Migration and refuses to fall back: without it `dotnet ef --startup-project …Api` resolves ConnectionStrings:Default, the runtime role, which cannot CREATE in schema public — and the obvious fix for that error is the ownership mistake the four-role split exists to prevent. Two architecture-test defects surfaced, both of which would have fired for every future module: - Modules_Do_Not_Inject_IEventBus_Directly flagged Vogen's generated nested TypeConverter, because TypeConverter.ConvertFrom takes an ITypeDescriptorContext and that implements IServiceProvider. Generated types are excluded now, walking the declaring chain because the attribute sits on the value object rather than the nested converter. - The same rule then flagged TenancyDbContext, because DbContext implements IInfrastructure and the check read INHERITED members. DeclaredOnly: the question is what THIS type does, and what a base type exposes is the base type's business. The migration is EF tool output, so the analyzer rules it trips (CA1861 on generated column arrays, IDE0161 on the block-scoped namespace, CA1707 on the snake_case name Standards 05 mandates) and its UTF-8 BOM are settled in .editorconfig for that folder rather than by hand-editing output the next regeneration discards. No runtime DI registration, deliberately: registering the context with its own connection string is precisely what ADR-0040 forbids, and would be removed again in step 6. The tests build the context against the fixture's connection. 776 tests green across four suites, 0 warnings under CI=true, format clean. Co-Authored-By: Claude Opus 5 (1M context) --- backend/.editorconfig | 28 + .../CompositeKeyedEntities.cs | 132 ++++ .../Enums.cs | 67 ++ .../Identifiers.cs | 28 + .../Organization.cs | 145 +++++ .../PlatformProjections.cs | 131 ++++ .../Tenant.cs | 146 +++++ .../TenantDomain.cs | 152 +++++ .../TenantSetting.cs | 108 ++++ ...tack.Modules.Tenancy.Infrastructure.csproj | 5 + .../Persistence/Configurations.cs | 305 +++++++++ ...28082209_create_tenancy_schema.Designer.cs | 489 ++++++++++++++ .../20260828082209_create_tenancy_schema.cs | 595 ++++++++++++++++++ .../TenancyDbContextModelSnapshot.cs | 486 ++++++++++++++ .../Persistence/SnakeCaseNaming.cs | 128 ++++ .../Persistence/TenancyDbContext.cs | 63 ++ .../Persistence/TenancyDbContextFactory.cs | 84 +++ .../CrossCuttingFoundationTests.cs | 41 +- .../Database/TenancySchemaTests.cs | 361 +++++++++++ .../LearnStack.Tests.Integration.csproj | 6 + 20 files changed, 3499 insertions(+), 1 deletion(-) create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Enums.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Identifiers.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828082209_create_tenancy_schema.Designer.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828082209_create_tenancy_schema.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/SnakeCaseNaming.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs diff --git a/backend/.editorconfig b/backend/.editorconfig index 0c10d4a3..a47a6374 100644 --- a/backend/.editorconfig +++ b/backend/.editorconfig @@ -23,3 +23,31 @@ dotnet_diagnostic.CS8601.severity = error dotnet_diagnostic.CS8602.severity = error dotnet_diagnostic.CS8603.severity = error dotnet_diagnostic.CS8604.severity = error + +# ─── EF Core migrations ────────────────────────────────────────────────── +# Migration files are TOOL OUTPUT: `dotnet ef migrations add` regenerates the +# CreateTable/CreateIndex calls, so a hand-edit to satisfy an analyzer is an +# edit the next regeneration discards. The hand-written half of a migration is +# the raw SQL appended to Up/Down, which these rules do not touch. +# +# CA1861 (prefer static readonly over constant array arguments) fires on every +# `columns: new[] { … }` EF emits. The rule is about repeated calls in hot +# paths; a migration's Up runs once, ever. +[**/Persistence/Migrations/*.cs] +dotnet_diagnostic.CA1861.severity = none + +# IDE0161: EF emits a block-scoped namespace. The repo requires file-scoped +# everywhere a human writes. +dotnet_diagnostic.IDE0161.severity = none + +# CA1707: the migration class takes its name from the file, and Database +# Standards § Migrations fixes that format as +# `_` with the intent in snake_case — so the +# underscores are the convention, not a lapse from it. +dotnet_diagnostic.CA1707.severity = none + +# EF writes migration files with a UTF-8 BOM. `dotnet format` fails them against +# the repo-wide `charset = utf-8`, and stripping it by hand is an edit the next +# `dotnet ef migrations add` undoes. +charset = utf-8-bom + diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs new file mode 100644 index 00000000..2ec868b7 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs @@ -0,0 +1,132 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.Modules.Tenancy.Domain; + +/// +/// A locale a tenant publishes in, per +/// ADR-0008. +/// +/// +/// +/// Composite natural key, no surrogate id, no AuditableEntity base. +/// The published shape is PRIMARY KEY (tenant_id, locale) +/// (12-localization.md), +/// and a locale row has no identity beyond the pair it is: a second row for the +/// same tenant and locale is not a second locale, it is a duplicate. Adding a +/// surrogate id to satisfy AuditableEntity<TId> would invent an +/// identity the domain does not have and contradict published DDL other documents +/// already reference. +/// +/// +/// It follows that these rows carry no audit columns and no row_version. +/// That is deliberate: they are a small, wholly-replaced set that the tenant's +/// own configuration audit covers as one change, not six. +/// +/// +public sealed class TenantLocale +{ + private TenantLocale() => Locale = null!; + + public TenantId TenantId { get; private set; } + + /// BCP-47 tag, lowercase region included — tr-TR, en-US. + public string Locale { get; private set; } + + /// Exactly one locale per tenant carries this. + public bool IsDefault { get; private set; } + + /// A disabled locale keeps its translations but is not offered. + public bool IsEnabled { get; private set; } + + /// Display order in a language switcher. + public short Sort { get; private set; } + + public static TenantLocale Create( + TenantId tenantId, string locale, bool isDefault, bool isEnabled = true, short sort = 0) + { + ArgumentException.ThrowIfNullOrWhiteSpace(locale); + + if (!tenantId.IsInitialized()) + { + throw new ArgumentException("A locale belongs to a tenant.", nameof(tenantId)); + } + + return new TenantLocale + { + TenantId = tenantId, + Locale = locale, + IsDefault = isDefault, + IsEnabled = isEnabled, + Sort = sort, + }; + } +} + +/// +/// A tenant-level feature-flag override — experimental, rollout, opt-in. +/// +/// +/// +/// Not plan-level features. Those live in the entitlement projection +/// (ADR-0021) +/// and are written only by IEntitlementProvider.RefreshAsync. This table is +/// the tenant's own switches, which is why a tenant may write it and may not write +/// the projection. +/// +/// +/// Composite natural key (tenant_id, key) and no surrogate id, for the same +/// reason as . It carries updated_at / +/// updated_by because a flag flip is worth attributing, but not the full +/// AuditableEntity set: a flag has no creation event distinct from its +/// first write, and no soft delete — removing a flag removes the row. +/// +/// +public sealed class TenantFeatureFlag +{ + private TenantFeatureFlag() + { + Key = null!; + Value = null!; + } + + public TenantId TenantId { get; private set; } + + public string Key { get; private set; } + + /// The flag's value as JSON — a boolean, a rollout percentage, a variant name. + public string Value { get; private set; } + + public DateTimeOffset UpdatedAt { get; private set; } + + public UserId UpdatedBy { get; private set; } + + public static TenantFeatureFlag Create( + TenantId tenantId, string key, string value, DateTimeOffset at, UserId by) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentException.ThrowIfNullOrWhiteSpace(value); + + if (!tenantId.IsInitialized()) + { + throw new ArgumentException("A feature flag belongs to a tenant.", nameof(tenantId)); + } + + return new TenantFeatureFlag + { + TenantId = tenantId, + Key = key, + Value = value, + UpdatedAt = at, + UpdatedBy = by, + }; + } + + public void SetValue(string value, DateTimeOffset at, UserId by) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + + Value = value; + UpdatedAt = at; + UpdatedBy = by; + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Enums.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Enums.cs new file mode 100644 index 00000000..12dc1dae --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Enums.cs @@ -0,0 +1,67 @@ +namespace LearnStack.Modules.Tenancy.Domain; + +/// +/// A tenant's lifecycle state. +/// +/// +/// Stored as text with a CHECK rather than a PostgreSQL enum +/// type or an int, per +/// Database Standards +/// § Constraints: an enum type's values can only be added, never removed or +/// reordered, and an int makes a database dump unreadable and a mistyped +/// value indistinguishable from a valid one. +/// +public enum TenantStatus +{ + /// Provisioned, not yet paying. The default at creation. + Trial = 0, + + /// Paying, or on a plan that needs no payment. + Active = 1, + + /// Access withdrawn — billing failure, policy breach. Reversible. + Suspended = 2, + + /// Ended. Retained for audit and retention obligations, never served. + Archived = 3, +} + +/// An organization's lifecycle state within its tenant. +public enum OrganizationStatus +{ + Active = 0, + Suspended = 1, + Archived = 2, +} + +/// How a host came to belong to a tenant. +public enum TenantDomainKind +{ + /// + /// A subdomain of the platform's own domain, always available and verified by + /// construction because the platform controls the zone. + /// + Subdomain = 0, + + /// + /// A domain the customer owns, which must be verified before it can serve. + /// + Custom = 1, +} + +/// +/// Where a domain is in the verification lifecycle. +/// +/// +/// The four states are fixed by +/// Phase 02a +/// Packet 6. A is created already +/// ; only a custom domain travels the whole path. +/// +public enum TenantDomainStatus +{ + Requested = 0, + Verifying = 1, + Verified = 2, + Failed = 3, +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Identifiers.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Identifiers.cs new file mode 100644 index 00000000..9e412df6 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Identifiers.cs @@ -0,0 +1,28 @@ +using LearnStack.SharedKernel; +using LearnStack.SharedKernel.Identifiers; +using Vogen; + +namespace LearnStack.Modules.Tenancy.Domain; + +/// +/// Identifies a host this tenant claims. +/// +/// +/// Module-local, unlike and : +/// nothing outside Tenancy holds one, so ADR-0023 Amendment 2's cross-cutting +/// placement rule does not apply and it stays with the aggregate it belongs to. +/// +[ValueObject(LearnStackVogenDefaults.IdMask)] +public readonly partial record struct TenantDomainId : IStronglyTypedId; + +/// +/// Identifies one tenant (or organization) configuration entry. +/// +/// +/// The row is addressed in queries by (tenant_id, organization_id, key); +/// this surrogate exists so the row is an AuditableEntity like any other — +/// a settings change is an audited mutation with a version, not an anonymous +/// upsert. +/// +[ValueObject(LearnStackVogenDefaults.IdMask)] +public readonly partial record struct TenantSettingId : IStronglyTypedId; diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs new file mode 100644 index 00000000..e40ef33e --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs @@ -0,0 +1,145 @@ +using LearnStack.SharedKernel.Domain; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Time; + +namespace LearnStack.Modules.Tenancy.Domain; + +/// +/// A sub-unit within a tenant — a branch, studio, campus, department or cohort — +/// per ADR-0017. +/// +/// +/// +/// Declared here and nowhere else. ADR-0017's original sample placed it in +/// LearnStack.Modules.Identity.Domain.Entities; Amendment 2 (2026-08-10) +/// moved it to Tenancy, and Identity holds by value +/// and reads organization data through an application contract. The architecture +/// rule Organization_Aggregate_Declared_In_Tenancy_Domain is what keeps a +/// second declaration from appearing. +/// +/// +/// The hierarchy is strictly two levels. is a +/// self-reference for reporting only — it is not an isolation boundary, nothing +/// resolves through it, and no policy reads it. A row's scope is its own +/// , never its parent's. +/// +/// +/// Branding is not here. ADR-0017's sample carries an +/// OrganizationBranding? override; the value object and the token merge +/// belong to Phase 06, +/// so the column arrives with them rather than as an unused jsonb nobody +/// writes. +/// +/// +public sealed class Organization : AuditableEntity, IAggregateRoot +{ + private Organization(OrganizationId id) + : base(id) + { + Slug = null!; + DisplayName = null!; + } + + // EF materialization. + private Organization() + { + Slug = null!; + DisplayName = null!; + } + + /// The tenant this organization belongs to. Immutable. + /// + /// An organization never moves between tenants, and neither does a row that + /// names it: its audit rows, its storage prefix and its cache-key prefix are + /// all tenant-qualified, so re-parenting would orphan three subsystems at + /// once. + /// + public TenantId TenantId { get; private set; } + + /// URL-safe handle, unique within the tenant. + public string Slug { get; private set; } + + /// Human-facing name. Not translated — an organization has one name. + public string DisplayName { get; private set; } + + /// + /// The organization's own subdomain, when it serves one. + /// + /// + /// Advisory here: what actually resolves a request is a + /// platform_host_to_tenant row, which is read before any tenant context + /// exists. This column records intent; the mapping table records resolution. + /// + public string? CustomSubdomain { get; private set; } + + /// Lifecycle state within the tenant. + public OrganizationStatus Status { get; private set; } + + /// + /// A reporting-only parent, for tenants whose branches roll up. + /// + /// + /// Not enforced as a hierarchy: no cycle check, no depth limit, and nothing + /// resolves through it. ADR-0017 keeps the isolation model flat precisely so + /// that a policy never has to walk a tree. + /// + public OrganizationId? ReportingParentId { get; private set; } + + /// + /// Creates an organization inside a tenant. + /// + /// + /// The id is supplied rather than minted here so the caller's + /// IGuidFactory is the single source of identifiers and a test can pin + /// it — per Standards 02 § Time. + /// + public static Organization Create( + OrganizationId id, + TenantId tenantId, + string slug, + string displayName, + IClock clock, + UserId createdBy) + { + ArgumentNullException.ThrowIfNull(clock); + ArgumentException.ThrowIfNullOrWhiteSpace(slug); + ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + + if (!tenantId.IsInitialized()) + { + throw new ArgumentException( + "An organization belongs to a tenant; the tenant id was never assigned.", + nameof(tenantId)); + } + + var organization = new Organization(id) + { + TenantId = tenantId, + Slug = slug, + DisplayName = displayName, + Status = OrganizationStatus.Active, + }; + + organization.MarkCreated(clock.UtcNow, createdBy); + return organization; + } + + /// Renames the organization. + public void Rename(string displayName, IClock clock, UserId updatedBy) + { + ArgumentNullException.ThrowIfNull(clock); + ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + + DisplayName = displayName; + MarkUpdated(clock.UtcNow, updatedBy); + } + + /// Moves the organization to a new lifecycle state. + public void ChangeStatus(OrganizationStatus status, IClock clock, UserId updatedBy) + { + ArgumentNullException.ThrowIfNull(clock); + + Status = status; + MarkUpdated(clock.UtcNow, updatedBy); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs new file mode 100644 index 00000000..686a6dac --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs @@ -0,0 +1,131 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.Modules.Tenancy.Domain; + +/// +/// The durable copy of a tenant's plan entitlements. +/// +/// +/// +/// Despite the table name this is a store, not a cache. It is the layer +/// that makes the grace window real: if it were evictable, a Hub outage would +/// revoke every tenant's plan. The learnstack.hub.entitlement event is the +/// eager invalidation signal for the L1/L2 caches in front of it, never the +/// write path. +/// +/// +/// Tenant-owned, not platform-scoped, despite the platform_ prefix. +/// Every read resolves the tenant from ITenantContext first, and every +/// write arrives on PUT /api/internal/tenants/{id}/entitlements with the +/// tenant in its path — both directions have a tenant, so the ordinary +/// tenant-owned policy applies and the application role never holds a table-wide +/// read of every tenant's plan +/// (Database Standards +/// § Table classes). +/// +/// +/// Written only through IEntitlementProvider.RefreshAsync. Modules never +/// write it directly, which Modules_Do_Not_Read_Entitlement_Cache_Directly +/// enforces. +/// +/// +public sealed class PlatformEntitlement +{ + private PlatformEntitlement() + { + PlanCode = null!; + Features = null!; + Limits = null!; + Compliance = null!; + Source = null!; + } + + /// One row per tenant; the tenant id is the primary key. + public TenantId TenantId { get; private set; } + + /// The plan. Carried on the wire as tier. + public string PlanCode { get; private set; } + + /// Feature switches, as JSON: Dictionary<string, bool>. + public string Features { get; private set; } + + /// Numeric ceilings, as JSON: Dictionary<string, long>. + public string Limits { get; private set; } + + /// Compliance caps, regions, retention overrides, as JSON. + public string Compliance { get; private set; } + + /// When the entitlement lapses. Carried on the wire as expires_at. + public DateTimeOffset ValidUntil { get; private set; } + + /// Bounds the grace window. Null unless in grace. + public DateTimeOffset? GraceUntil { get; private set; } + + /// + /// Monotonic. A push is accepted only when its generation is at least the + /// stored one, so a replayed or out-of-order projection cannot roll a tenant + /// back onto an older plan. + /// + public long Generation { get; private set; } + + public DateTimeOffset RefreshedAt { get; private set; } + + /// hub | signed-license-key | null-provider. + public string Source { get; private set; } +} + +/// +/// The host → tenant resolution index. Read before any tenant is known. +/// +/// +/// +/// The one platform-scoped table: IHostToTenantResolver reads it in +/// order to determine the tenant, so the ordinary tenant-owned predicate +/// would return zero rows and no tenant could ever resolve. Its policies are +/// role-qualified and per-command — the read admits the single row the resolver +/// announces through app.resolving_host, writes stay tenant-keyed +/// (Database Standards +/// § Table classes). +/// +/// +/// Never populated by calling the Hub. Rows arrive from +/// PUT /api/internal/tenants/{id}/host-mappings or from configuration; an +/// anonymous page load must not depend on a control plane being reachable +/// (ADR-0034). +/// +/// +public sealed class PlatformHostMapping +{ + private PlatformHostMapping() => Host = null!; + + /// The normalized effective host. Primary key — one answer per host. + public string Host { get; private set; } + + public TenantId TenantId { get; private set; } + + /// Null for a tenant-wide host; set when the host serves one organization. + public OrganizationId? OrganizationId { get; private set; } + + /// + /// The mapping exists and is owned by this tenant. + /// + /// + /// Distinct from on purpose. A row exists before + /// DNS points at LearnStack — the lifecycle is submit → row → DNS instructions + /// → activate. + /// + public bool IsActive { get; private set; } + + /// + /// The host may serve anonymous traffic. + /// + /// + /// Without this separate flag, guessing a hostname serves an unlaunched + /// tenant's pre-launch catalog, pricing and branding to a stranger, and a + /// released-then-re-registered domain serves the previous tenant's content for + /// the resolver cache's window + /// (ADR-0036). + /// A host-only request requires it. + /// + public bool IsPubliclyLive { get; private set; } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs new file mode 100644 index 00000000..2fd06a59 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs @@ -0,0 +1,146 @@ +using LearnStack.SharedKernel.Domain; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Time; + +namespace LearnStack.Modules.Tenancy.Domain; + +/// +/// The root of a customer's data. Every other tenant-owned row keys on its id. +/// +/// +/// +/// Self-keyed, and that shapes its whole lifecycle. tenants has no +/// tenant_id column — its primary key is the tenant id — so its Row +/// Level Security policy keys on id +/// (Database Standards +/// § Table classes). Two consequences follow, and both are binding: +/// +/// +/// The id is never minted here. The registry that owns the tenant assigns +/// it — the Hub in SaaS / Dedicated, configuration in Self-Hosted, the fixture in +/// a seed — and the provisioning transaction sets app.tenant_id to that +/// value before the INSERT, so WITH CHECK passes. A factory that +/// generated its own id could not satisfy its own policy. +/// +/// +/// Enumerating tenants needs the platform role. SELECT … FROM tenants +/// with no app.tenant_id returns zero rows, so every operator list screen +/// and every cross-tenant sweep goes through EnterPlatformAdminScope(reason). +/// That is the intended cost: the application role cannot enumerate the customer +/// list. +/// +/// +public sealed class Tenant : AuditableEntity, IAggregateRoot +{ + private Tenant(TenantId id) + : base(id) + { + Slug = null!; + DisplayName = null!; + } + + // EF materialization. + private Tenant() + { + Slug = null!; + DisplayName = null!; + } + + /// + /// Globally unique, URL-safe handle. Appears in hostnames. + /// + /// + /// Unique across the whole table rather than per tenant, and PostgreSQL + /// enforces unique indexes with row security bypassed — so a duplicate-slug + /// insert reveals that some tenant already holds the slug. Accepted + /// here because slugs appear in hostnames and are public by construction. It + /// is accepted nowhere else, which is why every other natural key is + /// UNIQUE (tenant_id, …). + /// + public string Slug { get; private set; } + + /// Human-facing name. + public string DisplayName { get; private set; } + + /// Lifecycle state. + public TenantStatus Status { get; private set; } + + /// + /// The organization a request falls back to when it names none. + /// + /// + /// Nullable, and null only inside the provisioning transaction. The foreign + /// key is composite — (id, default_organization_id) REFERENCES + /// organizations (tenant_id, id) — so it cannot point at another tenant's + /// organization even though referential-integrity checks run with row + /// security bypassed. Under MATCH SIMPLE the check is skipped entirely + /// while the column is null, which is what makes the three-statement + /// provisioning sequence work: insert the tenant, insert its organization, + /// then . + /// + public OrganizationId? DefaultOrganizationId { get; private set; } + + /// + /// Creates a tenant under an id its registry has already assigned. + /// + public static Tenant Create( + TenantId id, + string slug, + string displayName, + IClock clock, + UserId createdBy) + { + ArgumentNullException.ThrowIfNull(clock); + ArgumentException.ThrowIfNullOrWhiteSpace(slug); + ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + + if (!id.IsInitialized()) + { + throw new ArgumentException( + "A tenant id is assigned by the registry that owns the tenant, never minted here.", + nameof(id)); + } + + var tenant = new Tenant(id) + { + Slug = slug, + DisplayName = displayName, + Status = TenantStatus.Trial, + }; + + tenant.MarkCreated(clock.UtcNow, createdBy); + return tenant; + } + + /// + /// Points the tenant at its default organization, completing provisioning. + /// + /// + /// Separate from because the organization cannot exist + /// before its tenant: the composite foreign key would have nothing to + /// reference. Both statements run in one transaction, so a tenant is never + /// observable without a default organization. + /// + public void AssignDefaultOrganization(OrganizationId organizationId, IClock clock, UserId updatedBy) + { + ArgumentNullException.ThrowIfNull(clock); + + if (!organizationId.IsInitialized()) + { + throw new ArgumentException( + "The default organization must be a real organization.", nameof(organizationId)); + } + + DefaultOrganizationId = organizationId; + MarkUpdated(clock.UtcNow, updatedBy); + } + + /// Moves the tenant to a new lifecycle state. + public void ChangeStatus(TenantStatus status, IClock clock, UserId updatedBy) + { + ArgumentNullException.ThrowIfNull(clock); + + Status = status; + MarkUpdated(clock.UtcNow, updatedBy); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs new file mode 100644 index 00000000..6e68e462 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs @@ -0,0 +1,152 @@ +using LearnStack.SharedKernel.Domain; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Time; + +namespace LearnStack.Modules.Tenancy.Domain; + +/// +/// A host a tenant claims, and where it is in the verification lifecycle. +/// +/// +/// +/// This is not the resolution index. platform_host_to_tenant is, +/// and the two exist separately because they are read at different moments: +/// this table is read and written under tenant context, as part of a +/// tenant managing its own domains, while the mapping table is read before +/// any tenant context exists, in order to determine the tenant. That difference +/// is exactly why they cannot share one Row Level Security rule — this one is +/// ordinary tenant-owned, the mapping table is the platform-scoped class. +/// +/// +/// A verified row here does not serve traffic on its own; a corresponding +/// mapping row does. The custom-domain lifecycle that keeps the two in step is +/// Hub-owned and lands in +/// Phase 02c; +/// what Packet 6 owns is the schema both sides write to. +/// +/// +public sealed class TenantDomain : AuditableEntity +{ + private TenantDomain(TenantDomainId id) + : base(id) => Host = null!; + + // EF materialization. + private TenantDomain() => Host = null!; + + public TenantId TenantId { get; private set; } + + /// + /// The normalized host, lowercase and punycoded. + /// + /// + /// Globally unique, not unique per tenant: a host resolving to two tenants is + /// unresolvable regardless of who owns it, and the mapping table already + /// assumes one answer per host. + /// + public string Host { get; private set; } + + public TenantDomainKind Kind { get; private set; } + + public TenantDomainStatus Status { get; private set; } + + /// When verification last succeeded. Null until it does. + public DateTimeOffset? VerifiedAt { get; private set; } + + /// How many verification attempts have run, for backoff and for support. + public int VerificationAttempts { get; private set; } + + /// + /// Why the last attempt failed, in operator-facing terms. + /// + /// + /// Carries no certificate material and no private key. CLAUDE.md forbids cert + /// material moving by value anywhere; it moves by secret-store replication and + /// is referenced by path. + /// + public string? LastVerificationError { get; private set; } + + /// + /// Claims a platform subdomain, which is verified by construction. + /// + public static TenantDomain CreateSubdomain( + TenantDomainId id, TenantId tenantId, string host, IClock clock, UserId createdBy) + { + var domain = CreateCore(id, tenantId, host, TenantDomainKind.Subdomain, clock, createdBy); + domain.Status = TenantDomainStatus.Verified; + domain.VerifiedAt = clock.UtcNow; + return domain; + } + + /// + /// Claims a customer-owned domain, which starts unverified. + /// + public static TenantDomain RequestCustomDomain( + TenantDomainId id, TenantId tenantId, string host, IClock clock, UserId createdBy) + => CreateCore(id, tenantId, host, TenantDomainKind.Custom, clock, createdBy); + + /// Records a verification attempt that succeeded. + public void MarkVerified(IClock clock, UserId updatedBy) + { + ArgumentNullException.ThrowIfNull(clock); + + Status = TenantDomainStatus.Verified; + VerifiedAt = clock.UtcNow; + VerificationAttempts++; + LastVerificationError = null; + MarkUpdated(clock.UtcNow, updatedBy); + } + + /// Records a verification attempt that failed. + public void MarkVerificationFailed(string error, IClock clock, UserId updatedBy) + { + ArgumentNullException.ThrowIfNull(clock); + ArgumentException.ThrowIfNullOrWhiteSpace(error); + + Status = TenantDomainStatus.Failed; + VerificationAttempts++; + LastVerificationError = error; + MarkUpdated(clock.UtcNow, updatedBy); + } + + private static TenantDomain CreateCore( + TenantDomainId id, + TenantId tenantId, + string host, + TenantDomainKind kind, + IClock clock, + UserId createdBy) + { + ArgumentNullException.ThrowIfNull(clock); + ArgumentException.ThrowIfNullOrWhiteSpace(host); + + if (!tenantId.IsInitialized()) + { + throw new ArgumentException("A domain belongs to a tenant.", nameof(tenantId)); + } + + // The database carries the same rule as a CHECK; this is the loud half, so + // a caller that skipped EffectiveHost.Normalize learns it here rather than + // as a constraint violation three layers down. + // `host.Any(char.IsUpper)`, not `host != host.ToLowerInvariant()`: the + // question is whether the string IS lowercase, not whether two strings + // match case-insensitively — and the analyzer's suggested fix for the + // latter (OrdinalIgnoreCase) would make this check always pass. + if (host.Any(char.IsUpper)) + { + throw new ArgumentException( + "Host must be normalized before it reaches the aggregate: lowercase, punycoded, no port, no trailing dot. Use EffectiveHost.Normalize.", + nameof(host)); + } + + var domain = new TenantDomain(id) + { + TenantId = tenantId, + Host = host, + Kind = kind, + Status = TenantDomainStatus.Requested, + }; + + domain.MarkCreated(clock.UtcNow, createdBy); + return domain; + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs new file mode 100644 index 00000000..afad6b9f --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs @@ -0,0 +1,108 @@ +using LearnStack.SharedKernel.Domain; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Time; + +namespace LearnStack.Modules.Tenancy.Domain; + +/// +/// One piece of non-translated tenant configuration, optionally overridden for a +/// single organization. +/// +/// +/// +/// Key/value with a jsonb payload, not a table of typed columns. +/// The corpus fixes the contents and never the shape, and +/// Database Standards +/// § Constraints supplies the decision procedure: an organization setting is +/// an override resolved through the documented org → tenant fallback chain, which +/// is the *authored precedence* branch, so belongs +/// in the key and the constraint is +/// UNIQUE NULLS NOT DISTINCT (tenant_id, organization_id, key). Without +/// NULLS NOT DISTINCT a tenant could hold unlimited duplicate tenant-wide +/// rows for one key — the rows a single-organization tenant creates exclusively — +/// and resolution would pick one arbitrarily. +/// +/// +/// The only organization-scoped table in the Packet 6 set, and therefore +/// the only one that takes the two AS RESTRICTIVE write guards and the +/// organization_id immutability trigger. A null organization means +/// tenant-wide — a scope, not "unknown". +/// +/// +public sealed class TenantSetting : AuditableEntity +{ + private TenantSetting(TenantSettingId id) + : base(id) + { + Key = null!; + Value = null!; + } + + // EF materialization. + private TenantSetting() + { + Key = null!; + Value = null!; + } + + public TenantId TenantId { get; private set; } + + /// Null means the setting applies tenant-wide. + public OrganizationId? OrganizationId { get; private set; } + + /// Dotted key, e.g. notifications.default-sender. + public string Key { get; private set; } + + /// The value, as JSON. The shape is the caller's to know. + public string Value { get; private set; } + + public static TenantSetting Create( + TenantSettingId id, + TenantId tenantId, + OrganizationId? organizationId, + string key, + string value, + IClock clock, + UserId createdBy) + { + ArgumentNullException.ThrowIfNull(clock); + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentException.ThrowIfNullOrWhiteSpace(value); + + if (!tenantId.IsInitialized()) + { + throw new ArgumentException("A setting belongs to a tenant.", nameof(tenantId)); + } + + var setting = new TenantSetting(id) + { + TenantId = tenantId, + OrganizationId = organizationId, + Key = key, + Value = value, + }; + + setting.MarkCreated(clock.UtcNow, createdBy); + return setting; + } + + /// + /// Replaces the value. The scope is not changeable. + /// + /// + /// There is deliberately no method to move a setting between organizations. + /// The database refuses it too — tg_tenant_settings_organization_id_immutable + /// fires on any UPDATE that changes the column, including to or from + /// null — because a row's audit trail, storage prefix and cache-key prefix are + /// all organization-qualified, so re-parenting would orphan three subsystems + /// at once. Moving a setting is a create plus a delete. + /// + public void SetValue(string value, IClock clock, UserId updatedBy) + { + ArgumentNullException.ThrowIfNull(clock); + ArgumentException.ThrowIfNullOrWhiteSpace(value); + + Value = value; + MarkUpdated(clock.UtcNow, updatedBy); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/LearnStack.Modules.Tenancy.Infrastructure.csproj b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/LearnStack.Modules.Tenancy.Infrastructure.csproj index aee81f86..656eacb6 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/LearnStack.Modules.Tenancy.Infrastructure.csproj +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/LearnStack.Modules.Tenancy.Infrastructure.csproj @@ -13,6 +13,11 @@ + + diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs new file mode 100644 index 00000000..4d421a5a --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs @@ -0,0 +1,305 @@ +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Domain; +using LearnStack.SharedKernel.Identifiers; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; + +/// +/// Shared mapping rules every Tenancy configuration applies. +/// +internal static class TenancyMapping +{ + /// + /// Maps the six audit columns and the concurrency token. + /// + /// + /// + /// IsConcurrencyToken() and nothing else. Adding + /// ValueGeneratedOnAddOrUpdate() — or the equivalent + /// IsRowVersion() — tells EF the database generates the value, and EF + /// then omits the column from the UPDATE entirely: measured, the + /// persisted value stays 0 for the life of the row, every + /// If-Match compares equal, and a lost update succeeds while reporting + /// success (ADR-0039 + /// Amendment 1). + /// + /// + /// updated_at / updated_by are nullable because + /// MarkCreated stamps neither: a row that has never been changed has no + /// updater, and NOT NULL would reject every insert. + /// deleted_at / deleted_by are unconditional because + /// AuditableEntity<TId> implements ISoftDelete for every + /// aggregate, so EF maps them whether the aggregate is ever soft-deleted or + /// not. + /// + /// + public static void MapAuditColumns(this EntityTypeBuilder builder) + where TEntity : AuditableEntity + where TId : struct, IStronglyTypedId, IEquatable + { + builder.Property(x => x.CreatedAt).IsRequired(); + builder.Property(x => x.CreatedBy) + .HasConversion() + .IsRequired(); + builder.Property(x => x.UpdatedAt); + builder.Property(x => x.UpdatedBy) + .HasConversion(); + builder.Property(x => x.DeletedAt); + builder.Property(x => x.DeletedBy) + .HasConversion(); + + builder.Property(x => x.Version) + .HasColumnName("row_version") + .HasDefaultValue(0L) + .IsConcurrencyToken(); + } + + /// + /// Maps a closed-set enum as text with the CLR name as the stored value. + /// + /// + /// Not a PostgreSQL enum type, whose values can only be added and never + /// removed or reordered, and not an int, which makes a dump unreadable + /// and a mistyped value indistinguishable from a valid one. The migration adds + /// the matching CHECK, which is what actually bounds the column — + /// this only decides how it is written. + /// + public static PropertyBuilder HasEnumAsText(this PropertyBuilder builder) + where TEnum : struct, Enum + => builder.HasConversion( + value => value.ToString(), + text => Enum.Parse(text, ignoreCase: false)); + +} + +internal sealed class TenantConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tenants"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .HasConversion() + .ValueGeneratedNever(); + + builder.Property(x => x.Slug).HasMaxLength(63).IsRequired(); + builder.Property(x => x.DisplayName).HasMaxLength(200).IsRequired(); + builder.Property(x => x.Status).HasEnumAsText().HasMaxLength(20).IsRequired(); + + builder.Property(x => x.DefaultOrganizationId) + .HasConversion(); + + // Globally unique, not per tenant: a slug appears in hostnames and is + // public by construction, which is why the leak a unique index causes + // (PostgreSQL enforces them with row security bypassed) is accepted here + // and nowhere else. + builder.HasIndex(x => x.Slug).IsUnique().HasDatabaseName("ux_tenants_slug"); + + builder.MapAuditColumns(); + } +} + +internal sealed class OrganizationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("organizations"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .HasConversion() + .ValueGeneratedNever(); + + builder.Property(x => x.TenantId) + .HasConversion() + .IsRequired(); + + builder.Property(x => x.Slug).HasMaxLength(63).IsRequired(); + builder.Property(x => x.DisplayName).HasMaxLength(200).IsRequired(); + builder.Property(x => x.CustomSubdomain).HasMaxLength(253); + builder.Property(x => x.Status).HasEnumAsText().HasMaxLength(20).IsRequired(); + + builder.Property(x => x.ReportingParentId) + .HasConversion(); + + builder.HasIndex(x => new { x.TenantId, x.Slug }) + .IsUnique() + .HasDatabaseName("ux_organizations_tenant_id_slug"); + + // Exists solely so tenants.default_organization_id — and every future + // org-scoped child — can carry a composite foreign key into this table. + // Looks redundant beside the primary key and is not: a single-column FK + // between two tenant-owned tables lets tenant A reference tenant B's row, + // because referential-integrity checks run with row security bypassed. + builder.HasIndex(x => new { x.TenantId, x.Id }) + .IsUnique() + .HasDatabaseName("ux_organizations_tenant_id_id"); + + builder.MapAuditColumns(); + } +} + +internal sealed class TenantDomainConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tenant_domains"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .HasConversion() + .ValueGeneratedNever(); + + builder.Property(x => x.TenantId) + .HasConversion() + .IsRequired(); + + builder.Property(x => x.Host).HasMaxLength(253).IsRequired(); + builder.Property(x => x.Kind).HasEnumAsText().HasMaxLength(20).IsRequired(); + builder.Property(x => x.Status).HasEnumAsText().HasMaxLength(20).IsRequired(); + builder.Property(x => x.VerifiedAt); + builder.Property(x => x.VerificationAttempts).HasDefaultValue(0).IsRequired(); + builder.Property(x => x.LastVerificationError).HasMaxLength(1000); + + // Globally unique: a host resolving to two tenants is unresolvable + // regardless of who owns it. + builder.HasIndex(x => x.Host).IsUnique().HasDatabaseName("ux_tenant_domains_host"); + builder.HasIndex(x => x.TenantId).HasDatabaseName("ix_tenant_domains_tenant_id"); + + builder.MapAuditColumns(); + } +} + +internal sealed class TenantLocaleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tenant_locales"); + + // Composite natural key, no surrogate id: a second row for the same + // tenant and locale is not a second locale, it is a duplicate. + builder.HasKey(x => new { x.TenantId, x.Locale }) + .HasName("pk_tenant_locales"); + + builder.Property(x => x.TenantId) + .HasConversion() + .IsRequired(); + + builder.Property(x => x.Locale).HasMaxLength(35).IsRequired(); + builder.Property(x => x.IsDefault).IsRequired(); + builder.Property(x => x.IsEnabled).HasDefaultValue(true).IsRequired(); + builder.Property(x => x.Sort).HasDefaultValue((short)0).IsRequired(); + } +} + +internal sealed class TenantSettingConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tenant_settings"); + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .HasConversion() + .ValueGeneratedNever(); + + builder.Property(x => x.TenantId) + .HasConversion() + .IsRequired(); + + builder.Property(x => x.OrganizationId) + .HasConversion(); + + builder.Property(x => x.Key).HasMaxLength(200).IsRequired(); + builder.Property(x => x.Value).HasColumnType("jsonb").IsRequired(); + + // The uniqueness is NULLS NOT DISTINCT, which EF cannot express — the + // migration replaces this index with the correct form. Declared here so + // the model still knows the shape; see the migration for why. + builder.HasIndex(x => new { x.TenantId, x.OrganizationId, x.Key }) + .IsUnique() + .HasDatabaseName("ux_tenant_settings_tenant_id_organization_id_key"); + + builder.HasIndex(x => new { x.TenantId, x.OrganizationId }) + .HasDatabaseName("ix_tenant_settings_tenant_id_organization_id"); + + builder.MapAuditColumns(); + } +} + +internal sealed class TenantFeatureFlagConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tenant_feature_flags"); + builder.HasKey(x => new { x.TenantId, x.Key }).HasName("pk_tenant_feature_flags"); + + builder.Property(x => x.TenantId) + .HasConversion() + .IsRequired(); + + builder.Property(x => x.Key).HasMaxLength(200).IsRequired(); + builder.Property(x => x.Value).HasColumnType("jsonb").IsRequired(); + builder.Property(x => x.UpdatedAt).IsRequired(); + + builder.Property(x => x.UpdatedBy) + .HasConversion() + .IsRequired(); + } +} + +internal sealed class PlatformEntitlementConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("platform_entitlement_cache"); + + // One row per tenant; the tenant id IS the key. + builder.HasKey(x => x.TenantId).HasName("pk_platform_entitlement_cache"); + + builder.Property(x => x.TenantId) + .HasConversion() + .ValueGeneratedNever(); + + builder.Property(x => x.PlanCode).HasMaxLength(100).IsRequired(); + builder.Property(x => x.Features).HasColumnType("jsonb").IsRequired(); + builder.Property(x => x.Limits).HasColumnType("jsonb").IsRequired(); + builder.Property(x => x.Compliance).HasColumnType("jsonb").IsRequired(); + builder.Property(x => x.ValidUntil).IsRequired(); + builder.Property(x => x.GraceUntil); + builder.Property(x => x.Generation).HasDefaultValue(1L).IsRequired(); + builder.Property(x => x.RefreshedAt).IsRequired(); + builder.Property(x => x.Source).HasMaxLength(50).IsRequired(); + } +} + +internal sealed class PlatformHostMappingConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("platform_host_to_tenant"); + + // The host is the key: one answer per host, enforced by the primary key + // rather than by a unique index over a surrogate. + builder.HasKey(x => x.Host).HasName("pk_platform_host_to_tenant"); + + builder.Property(x => x.Host).HasMaxLength(253).IsRequired(); + + builder.Property(x => x.TenantId) + .HasConversion() + .IsRequired(); + + builder.Property(x => x.OrganizationId) + .HasConversion(); + + builder.Property(x => x.IsActive).IsRequired(); + builder.Property(x => x.IsPubliclyLive).IsRequired(); + + // A tenant listing its own hosts is the second of the two read paths the + // policy admits, so it gets an index; the first (by host) is the key. + builder.HasIndex(x => x.TenantId).HasDatabaseName("ix_platform_host_to_tenant_tenant_id"); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828082209_create_tenancy_schema.Designer.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828082209_create_tenancy_schema.Designer.cs new file mode 100644 index 00000000..efd2cbb2 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828082209_create_tenancy_schema.Designer.cs @@ -0,0 +1,489 @@ +// +using System; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(TenancyDbContext))] + [Migration("20260828082209_create_tenancy_schema")] + partial class create_tenancy_schema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("CustomSubdomain") + .HasMaxLength(253) + .HasColumnType("character varying(253)") + .HasColumnName("custom_subdomain"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ReportingParentId") + .HasColumnType("uuid") + .HasColumnName("reporting_parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)") + .HasColumnName("slug"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_organizations"); + + b.HasIndex("TenantId", "Id") + .IsUnique() + .HasDatabaseName("ux_organizations_tenant_id_id"); + + b.HasIndex("TenantId", "Slug") + .IsUnique() + .HasDatabaseName("ux_organizations_tenant_id_slug"); + + b.ToTable("organizations", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.PlatformEntitlement", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Compliance") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("compliance"); + + b.Property("Features") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("features"); + + b.Property("Generation") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L) + .HasColumnName("generation"); + + b.Property("GraceUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("grace_until"); + + b.Property("Limits") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("limits"); + + b.Property("PlanCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("plan_code"); + + b.Property("RefreshedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("refreshed_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_until"); + + b.HasKey("TenantId") + .HasName("pk_platform_entitlement_cache"); + + b.ToTable("platform_entitlement_cache", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.PlatformHostMapping", b => + { + b.Property("Host") + .HasMaxLength(253) + .HasColumnType("character varying(253)") + .HasColumnName("host"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsPubliclyLive") + .HasColumnType("boolean") + .HasColumnName("is_publicly_live"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Host") + .HasName("pk_platform_host_to_tenant"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_platform_host_to_tenant_tenant_id"); + + b.ToTable("platform_host_to_tenant", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.Tenant", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DefaultOrganizationId") + .HasColumnType("uuid") + .HasColumnName("default_organization_id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)") + .HasColumnName("slug"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_tenants"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_tenants_slug"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantDomain", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)") + .HasColumnName("host"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("kind"); + + b.Property("LastVerificationError") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("last_verification_error"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("VerificationAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("verification_attempts"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("verified_at"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_tenant_domains"); + + b.HasIndex("Host") + .IsUnique() + .HasDatabaseName("ux_tenant_domains_host"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_tenant_domains_tenant_id"); + + b.ToTable("tenant_domains", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantFeatureFlag", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Value") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("value"); + + b.HasKey("TenantId", "Key") + .HasName("pk_tenant_feature_flags"); + + b.ToTable("tenant_feature_flags", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantLocale", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Locale") + .HasMaxLength(35) + .HasColumnType("character varying(35)") + .HasColumnName("locale"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_enabled"); + + b.Property("Sort") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((short)0) + .HasColumnName("sort"); + + b.HasKey("TenantId", "Locale") + .HasName("pk_tenant_locales"); + + b.ToTable("tenant_locales", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantSetting", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Value") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("value"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_tenant_settings"); + + b.HasIndex("TenantId", "OrganizationId") + .HasDatabaseName("ix_tenant_settings_tenant_id_organization_id"); + + b.HasIndex("TenantId", "OrganizationId", "Key") + .IsUnique() + .HasDatabaseName("ux_tenant_settings_tenant_id_organization_id_key"); + + b.ToTable("tenant_settings", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828082209_create_tenancy_schema.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828082209_create_tenancy_schema.cs new file mode 100644 index 00000000..0da0f420 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828082209_create_tenancy_schema.cs @@ -0,0 +1,595 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence.Migrations +{ + /// + public partial class create_tenancy_schema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "organizations", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + slug = table.Column(type: "character varying(63)", maxLength: 63, nullable: false), + display_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + custom_subdomain = table.Column(type: "character varying(253)", maxLength: 253, nullable: true), + status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + reporting_parent_id = table.Column(type: "uuid", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "uuid", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "uuid", nullable: true), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true), + deleted_by = table.Column(type: "uuid", nullable: true), + row_version = table.Column(type: "bigint", nullable: false, defaultValue: 0L) + }, + constraints: table => + { + table.PrimaryKey("pk_organizations", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "platform_entitlement_cache", + columns: table => new + { + tenant_id = table.Column(type: "uuid", nullable: false), + plan_code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + features = table.Column(type: "jsonb", nullable: false), + limits = table.Column(type: "jsonb", nullable: false), + compliance = table.Column(type: "jsonb", nullable: false), + valid_until = table.Column(type: "timestamp with time zone", nullable: false), + grace_until = table.Column(type: "timestamp with time zone", nullable: true), + generation = table.Column(type: "bigint", nullable: false, defaultValue: 1L), + refreshed_at = table.Column(type: "timestamp with time zone", nullable: false), + source = table.Column(type: "character varying(50)", maxLength: 50, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_platform_entitlement_cache", x => x.tenant_id); + }); + + migrationBuilder.CreateTable( + name: "platform_host_to_tenant", + columns: table => new + { + host = table.Column(type: "character varying(253)", maxLength: 253, nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + organization_id = table.Column(type: "uuid", nullable: true), + is_active = table.Column(type: "boolean", nullable: false), + is_publicly_live = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_platform_host_to_tenant", x => x.host); + }); + + migrationBuilder.CreateTable( + name: "tenant_domains", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + host = table.Column(type: "character varying(253)", maxLength: 253, nullable: false), + kind = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + verified_at = table.Column(type: "timestamp with time zone", nullable: true), + verification_attempts = table.Column(type: "integer", nullable: false, defaultValue: 0), + last_verification_error = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "uuid", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "uuid", nullable: true), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true), + deleted_by = table.Column(type: "uuid", nullable: true), + row_version = table.Column(type: "bigint", nullable: false, defaultValue: 0L) + }, + constraints: table => + { + table.PrimaryKey("pk_tenant_domains", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "tenant_feature_flags", + columns: table => new + { + tenant_id = table.Column(type: "uuid", nullable: false), + key = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + value = table.Column(type: "jsonb", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_by = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_tenant_feature_flags", x => new { x.tenant_id, x.key }); + }); + + migrationBuilder.CreateTable( + name: "tenant_locales", + columns: table => new + { + tenant_id = table.Column(type: "uuid", nullable: false), + locale = table.Column(type: "character varying(35)", maxLength: 35, nullable: false), + is_default = table.Column(type: "boolean", nullable: false), + is_enabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + sort = table.Column(type: "smallint", nullable: false, defaultValue: (short)0) + }, + constraints: table => + { + table.PrimaryKey("pk_tenant_locales", x => new { x.tenant_id, x.locale }); + }); + + migrationBuilder.CreateTable( + name: "tenant_settings", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false), + organization_id = table.Column(type: "uuid", nullable: true), + key = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + value = table.Column(type: "jsonb", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "uuid", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "uuid", nullable: true), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true), + deleted_by = table.Column(type: "uuid", nullable: true), + row_version = table.Column(type: "bigint", nullable: false, defaultValue: 0L) + }, + constraints: table => + { + table.PrimaryKey("pk_tenant_settings", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "tenants", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + slug = table.Column(type: "character varying(63)", maxLength: 63, nullable: false), + display_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + default_organization_id = table.Column(type: "uuid", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + created_by = table.Column(type: "uuid", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "uuid", nullable: true), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true), + deleted_by = table.Column(type: "uuid", nullable: true), + row_version = table.Column(type: "bigint", nullable: false, defaultValue: 0L) + }, + constraints: table => + { + table.PrimaryKey("pk_tenants", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ux_organizations_tenant_id_id", + table: "organizations", + columns: new[] { "tenant_id", "id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ux_organizations_tenant_id_slug", + table: "organizations", + columns: new[] { "tenant_id", "slug" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_platform_host_to_tenant_tenant_id", + table: "platform_host_to_tenant", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "ix_tenant_domains_tenant_id", + table: "tenant_domains", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "ux_tenant_domains_host", + table: "tenant_domains", + column: "host", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_tenant_settings_tenant_id_organization_id", + table: "tenant_settings", + columns: new[] { "tenant_id", "organization_id" }); + + migrationBuilder.CreateIndex( + name: "ux_tenant_settings_tenant_id_organization_id_key", + table: "tenant_settings", + columns: new[] { "tenant_id", "organization_id", "key" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ux_tenants_slug", + table: "tenants", + column: "slug", + unique: true); + + // ──────────────────────────────────────────────────────────────── + // Everything below is what EF Core cannot express, and it is not + // optional decoration: without it the eight tables above have no + // isolation at all. The canonical forms live in + // docs/standards/05-database.md; this migration transcribes them. + // ──────────────────────────────────────────────────────────────── + + // ── The circular foreign key, composite ────────────────────────── + // tenants.default_organization_id -> organizations, and organizations + // -> tenants. Composite on the tenant term because referential-integrity + // checks run with row security bypassed: single-column, tenant A could + // commit a permanent pointer at tenant B's organization — a row A cannot + // even see. Measured. Under MATCH SIMPLE the check is skipped while the + // column is null, which is what makes the three-statement provisioning + // sequence work (insert tenant, insert organization, UPDATE tenant). + migrationBuilder.Sql(""" + ALTER TABLE organizations + ADD CONSTRAINT fk_organizations_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE RESTRICT; + + ALTER TABLE tenants + ADD CONSTRAINT fk_tenants_default_organization + FOREIGN KEY (id, default_organization_id) REFERENCES organizations (tenant_id, id) + ON DELETE RESTRICT; + """); + + // organizations.reporting_parent_id is reporting-only and NOT an + // isolation boundary, but the composite rule still applies: it points + // at another organization in the same tenant, and single-column would + // reopen the same hole. + migrationBuilder.Sql(""" + ALTER TABLE organizations + ADD CONSTRAINT fk_organizations_reporting_parent + FOREIGN KEY (tenant_id, reporting_parent_id) REFERENCES organizations (tenant_id, id) + ON DELETE RESTRICT; + + ALTER TABLE tenant_domains + ADD CONSTRAINT fk_tenant_domains_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE RESTRICT; + + ALTER TABLE tenant_locales + ADD CONSTRAINT fk_tenant_locales_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE RESTRICT; + + ALTER TABLE tenant_settings + ADD CONSTRAINT fk_tenant_settings_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE RESTRICT; + + ALTER TABLE tenant_settings + ADD CONSTRAINT fk_tenant_settings_organization + FOREIGN KEY (tenant_id, organization_id) REFERENCES organizations (tenant_id, id) + ON DELETE RESTRICT; + + ALTER TABLE tenant_feature_flags + ADD CONSTRAINT fk_tenant_feature_flags_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE RESTRICT; + + ALTER TABLE platform_entitlement_cache + ADD CONSTRAINT fk_platform_entitlement_cache_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE RESTRICT; + + ALTER TABLE platform_host_to_tenant + ADD CONSTRAINT fk_platform_host_to_tenant_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants (id) ON DELETE RESTRICT; + + ALTER TABLE platform_host_to_tenant + ADD CONSTRAINT fk_platform_host_to_tenant_organization + FOREIGN KEY (tenant_id, organization_id) REFERENCES organizations (tenant_id, id) + ON DELETE RESTRICT; + """); + + // ── Closed-set columns: text + CHECK ───────────────────────────── + // Not a PostgreSQL enum type, whose values can only be added and never + // removed or reordered, and not an int, which makes a dump unreadable. + migrationBuilder.Sql(""" + ALTER TABLE tenants ADD CONSTRAINT ck_tenants_status + CHECK (status IN ('Trial', 'Active', 'Suspended', 'Archived')); + + ALTER TABLE organizations ADD CONSTRAINT ck_organizations_status + CHECK (status IN ('Active', 'Suspended', 'Archived')); + + ALTER TABLE tenant_domains ADD CONSTRAINT ck_tenant_domains_kind + CHECK (kind IN ('Subdomain', 'Custom')); + + ALTER TABLE tenant_domains ADD CONSTRAINT ck_tenant_domains_status + CHECK (status IN ('Requested', 'Verifying', 'Verified', 'Failed')); + + ALTER TABLE platform_entitlement_cache ADD CONSTRAINT ck_platform_entitlement_cache_source + CHECK (source IN ('hub', 'signed-license-key', 'null-provider')); + """); + + // ── Host normalization, as a backstop ──────────────────────────── + // The LDH rule stated positively: every label starts and ends + // alphanumeric and may carry hyphens between, labels joined by single + // dots. Written this way rather than as prohibitions because the + // prohibitions kept missing cases — a `!~ '[^a-z0-9.-]'` form accepted + // `.example.com`, `a..b.com` and `-example.com`, none of which + // EffectiveHost.Normalize's IsLdh gate can produce. Lowercase, no + // trailing dot and no embedded port all fall out of the pattern. + // `[a-z0-9]+(` rather than `[a-z0-9](`: the latter spells `](`, which + // the CI link audit greps for as a Markdown link. + migrationBuilder.Sql(""" + ALTER TABLE platform_host_to_tenant + ADD CONSTRAINT ck_platform_host_to_tenant_host_normalized CHECK ( + host ~ '^[a-z0-9]+([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]+([a-z0-9-]*[a-z0-9])?)*$' + AND length(host) <= 253); + + ALTER TABLE tenant_domains + ADD CONSTRAINT ck_tenant_domains_host_normalized CHECK ( + host ~ '^[a-z0-9]+([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]+([a-z0-9-]*[a-z0-9])?)*$' + AND length(host) <= 253); + """); + + // ── tenant_settings uniqueness: NULLS NOT DISTINCT ─────────────── + // EF cannot express it, so the index it generated is dropped and + // replaced. Load-bearing: organization_id is null on every tenant-wide + // row, and a standard UNIQUE treats nulls as distinct — so without this + // a tenant could hold unlimited duplicate tenant-wide rows for one key, + // which is precisely the set a single-organization tenant creates + // exclusively, and resolution would pick one arbitrarily. + migrationBuilder.Sql(""" + DROP INDEX ux_tenant_settings_tenant_id_organization_id_key; + + ALTER TABLE tenant_settings + ADD CONSTRAINT ux_tenant_settings_tenant_id_organization_id_key + UNIQUE NULLS NOT DISTINCT (tenant_id, organization_id, key); + """); + + // ── organization_id is immutable after insert ──────────────────── + // IS DISTINCT FROM rather than <>, so a move to or from NULL — + // tenant-wide to org-scoped, or back — is caught too; <> is NULL when + // either side is null and the trigger would pass. The restrictive + // UPDATE guard does not cover this: it admits the row when the NEW + // organization_id is the caller's own, which is exactly the + // re-parenting move. A row does not move between organizations because + // its audit rows, its storage prefix and its cache-key prefix are all + // organization-qualified. + migrationBuilder.Sql(""" + CREATE FUNCTION fn_organization_id_immutable() RETURNS trigger AS $$ + BEGIN + IF NEW.organization_id IS DISTINCT FROM OLD.organization_id THEN + RAISE EXCEPTION + 'organization_id is immutable after insert (table %, row %)', + TG_TABLE_NAME, OLD.id + USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER tg_tenant_settings_organization_id_immutable + BEFORE UPDATE ON tenant_settings + FOR EACH ROW EXECUTE FUNCTION fn_organization_id_immutable(); + """); + + // ── Row Level Security ─────────────────────────────────────────── + // ENABLE *and* FORCE on all eight, with no exception list, so the + // structural scan needs none. FORCE is what stops the owner bypassing + // its own policies — and learnstack_migration, which owns every table + // here, is NOBYPASSRLS precisely so FORCE means something. + // + // ONE permissive policy per table with an AND-ed predicate. Two + // permissive policies are combined with OR, which WIDENS access: that + // is the defect ADR-0003 Amendment 3 corrects, and it made every + // tenant-wide row visible to every tenant. + // + // NULLIF(..., '') is not decoration. A customized (dotted) GUC becomes a + // session placeholder the first time it is assigned, and its reset value + // is the empty string, not "undefined". On a pooled connection whose + // previous transaction set app.tenant_id and whose next one forgets to, + // current_setting(..., true) returns '' and ''::uuid RAISES instead of + // filtering. NULLIF turns that into NULL, and a NULL policy result is + // false for both USING and WITH CHECK — fail-closed for the never-set + // path and the reset path alike. + migrationBuilder.Sql(""" + ALTER TABLE tenants ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenants FORCE ROW LEVEL SECURITY; + ALTER TABLE organizations ENABLE ROW LEVEL SECURITY; + ALTER TABLE organizations FORCE ROW LEVEL SECURITY; + ALTER TABLE tenant_domains ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_domains FORCE ROW LEVEL SECURITY; + ALTER TABLE tenant_locales ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_locales FORCE ROW LEVEL SECURITY; + ALTER TABLE tenant_settings ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_settings FORCE ROW LEVEL SECURITY; + ALTER TABLE tenant_feature_flags ENABLE ROW LEVEL SECURITY; + ALTER TABLE tenant_feature_flags FORCE ROW LEVEL SECURITY; + ALTER TABLE platform_entitlement_cache ENABLE ROW LEVEL SECURITY; + ALTER TABLE platform_entitlement_cache FORCE ROW LEVEL SECURITY; + ALTER TABLE platform_host_to_tenant ENABLE ROW LEVEL SECURITY; + ALTER TABLE platform_host_to_tenant FORCE ROW LEVEL SECURITY; + """); + + // Class 1 — tenant-owned, SELF-KEYED. `tenants` has no tenant_id + // column; its id IS the tenant id, so the predicate keys on id. + migrationBuilder.Sql(""" + CREATE POLICY tenants_isolation ON tenants + USING (id = NULLIF(current_setting('app.tenant_id', true), '')::uuid) + WITH CHECK (id = NULLIF(current_setting('app.tenant_id', true), '')::uuid); + """); + + // Class 2 — tenant-owned, TENANT-WIDE. No organization term, because + // these tables carry no organization_id column, and therefore no + // restrictive guards either: there is no organization to guard. + migrationBuilder.Sql(""" + CREATE POLICY organizations_isolation ON organizations + USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid) + WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid); + + CREATE POLICY tenant_domains_isolation ON tenant_domains + USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid) + WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid); + + CREATE POLICY tenant_locales_isolation ON tenant_locales + USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid) + WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid); + + CREATE POLICY tenant_feature_flags_isolation ON tenant_feature_flags + USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid) + WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid); + + CREATE POLICY platform_entitlement_cache_isolation ON platform_entitlement_cache + USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid) + WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid); + """); + + // Class 2b — tenant-owned, ORG-SCOPED. The only one in this set, and + // therefore the only table taking the full template: the AND-ed + // organization term, the app.scope read hatch, and the two AS + // RESTRICTIVE write guards. + // + // The hatch widens READS across organizations, which cross-org + // reporting needs. It must not widen writes — but USING is also what + // selects the rows an UPDATE may target, and for DELETE it is the ONLY + // gate, because PostgreSQL has no WITH CHECK for DELETE. Without the + // restrictive policies a tenant-scope session could delete another + // organization's rows, or reassign them to itself. + migrationBuilder.Sql(""" + CREATE POLICY tenant_settings_isolation ON tenant_settings + USING ( + tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid + AND ( + organization_id IS NULL + OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid + OR current_setting('app.scope', true) = 'tenant' + ) + ) + WITH CHECK ( + tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid + AND ( + organization_id IS NULL + OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid + ) + ); + + CREATE POLICY tenant_settings_org_write_guard ON tenant_settings + AS RESTRICTIVE FOR UPDATE + USING ( + organization_id IS NULL + OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid + ); + + CREATE POLICY tenant_settings_org_delete_guard ON tenant_settings + AS RESTRICTIVE FOR DELETE + USING ( + organization_id IS NULL + OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid + ); + """); + + // Class 3 — PLATFORM-SCOPED. One table, and adding a second is a + // decision rather than a convenience. IHostToTenantResolver reads it in + // order to DETERMINE the tenant, so at that moment app.tenant_id is + // unset and the tenant-owned template would return zero rows forever. + // The answer is not to drop row security — a table without it is + // indistinguishable from one nobody thought about — but to give the + // read an explicitly declared key of its own: the resolver announces + // the host it is about to resolve, and the policy admits exactly that + // row. + // + // A wide SELECT policy does not widen UPDATE or DELETE: PostgreSQL + // applies the command's own policies in addition to the SELECT policy, + // and a row must satisfy both. A session that can see another tenant's + // host through app.resolving_host still cannot repoint it. + // + // Because these are role-qualified TO learnstack_app, no policy applies + // to the owner, so under FORCE every access by learnstack_migration to + // this table is denied. Rows arrive through learnstack_app under tenant + // context or through learnstack_platform. + migrationBuilder.Sql(""" + CREATE POLICY platform_host_to_tenant_read ON platform_host_to_tenant + FOR SELECT TO learnstack_app + USING ( + host = NULLIF(current_setting('app.resolving_host', true), '') + OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid + ); + + CREATE POLICY platform_host_to_tenant_insert ON platform_host_to_tenant + FOR INSERT TO learnstack_app + WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid); + + CREATE POLICY platform_host_to_tenant_update ON platform_host_to_tenant + FOR UPDATE TO learnstack_app + USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid) + WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid); + + CREATE POLICY platform_host_to_tenant_delete ON platform_host_to_tenant + FOR DELETE TO learnstack_app + USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid); + """); + + // ── Grants ─────────────────────────────────────────────────────── + // Written here, in the migration that creates the tables, because + // there is deliberately no ALTER DEFAULT PRIVILEGES: a table nobody + // granted fails loudly with `permission denied` rather than silently + // inheriting DML — and can never silently widen a BYPASSRLS role, whose + // only bound is this matrix. learnstack_migration owns every table and + // needs no grant. The matrix is docs/standards/05-database.md § GRANT + // matrix; this transcribes it. + migrationBuilder.Sql(""" + GRANT SELECT, INSERT, UPDATE ON tenants TO learnstack_app; + GRANT SELECT, INSERT, UPDATE, DELETE ON tenants TO learnstack_platform; + + GRANT SELECT, INSERT, UPDATE, DELETE ON organizations TO learnstack_app; + GRANT SELECT, INSERT, UPDATE, DELETE ON organizations TO learnstack_platform; + + GRANT SELECT, INSERT, UPDATE, DELETE ON tenant_domains TO learnstack_app; + GRANT SELECT ON tenant_domains TO learnstack_platform; + + GRANT SELECT, INSERT, UPDATE, DELETE ON tenant_locales TO learnstack_app; + GRANT SELECT ON tenant_locales TO learnstack_platform; + + GRANT SELECT, INSERT, UPDATE, DELETE ON tenant_settings TO learnstack_app; + GRANT SELECT ON tenant_settings TO learnstack_platform; + + GRANT SELECT, INSERT, UPDATE, DELETE ON tenant_feature_flags TO learnstack_app; + GRANT SELECT, INSERT, UPDATE, DELETE ON tenant_feature_flags TO learnstack_platform; + + GRANT SELECT, INSERT, UPDATE ON platform_entitlement_cache TO learnstack_app; + GRANT SELECT, DELETE ON platform_entitlement_cache TO learnstack_platform; + + GRANT SELECT, INSERT, UPDATE, DELETE ON platform_host_to_tenant TO learnstack_app; + GRANT SELECT, INSERT, UPDATE, DELETE ON platform_host_to_tenant TO learnstack_platform; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // The tables go with DropTable below; what needs explicit removal is + // the one object that is not owned by a table. + migrationBuilder.Sql("DROP FUNCTION IF EXISTS fn_organization_id_immutable();"); + + migrationBuilder.DropTable( + name: "organizations"); + + migrationBuilder.DropTable( + name: "platform_entitlement_cache"); + + migrationBuilder.DropTable( + name: "platform_host_to_tenant"); + + migrationBuilder.DropTable( + name: "tenant_domains"); + + migrationBuilder.DropTable( + name: "tenant_feature_flags"); + + migrationBuilder.DropTable( + name: "tenant_locales"); + + migrationBuilder.DropTable( + name: "tenant_settings"); + + migrationBuilder.DropTable( + name: "tenants"); + } + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs new file mode 100644 index 00000000..85be2ab5 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs @@ -0,0 +1,486 @@ +// +using System; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(TenancyDbContext))] + partial class TenancyDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("CustomSubdomain") + .HasMaxLength(253) + .HasColumnType("character varying(253)") + .HasColumnName("custom_subdomain"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("ReportingParentId") + .HasColumnType("uuid") + .HasColumnName("reporting_parent_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)") + .HasColumnName("slug"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_organizations"); + + b.HasIndex("TenantId", "Id") + .IsUnique() + .HasDatabaseName("ux_organizations_tenant_id_id"); + + b.HasIndex("TenantId", "Slug") + .IsUnique() + .HasDatabaseName("ux_organizations_tenant_id_slug"); + + b.ToTable("organizations", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.PlatformEntitlement", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Compliance") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("compliance"); + + b.Property("Features") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("features"); + + b.Property("Generation") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L) + .HasColumnName("generation"); + + b.Property("GraceUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("grace_until"); + + b.Property("Limits") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("limits"); + + b.Property("PlanCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("plan_code"); + + b.Property("RefreshedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("refreshed_at"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_until"); + + b.HasKey("TenantId") + .HasName("pk_platform_entitlement_cache"); + + b.ToTable("platform_entitlement_cache", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.PlatformHostMapping", b => + { + b.Property("Host") + .HasMaxLength(253) + .HasColumnType("character varying(253)") + .HasColumnName("host"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsPubliclyLive") + .HasColumnType("boolean") + .HasColumnName("is_publicly_live"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Host") + .HasName("pk_platform_host_to_tenant"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_platform_host_to_tenant_tenant_id"); + + b.ToTable("platform_host_to_tenant", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.Tenant", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DefaultOrganizationId") + .HasColumnType("uuid") + .HasColumnName("default_organization_id"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)") + .HasColumnName("slug"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_tenants"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ux_tenants_slug"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantDomain", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("character varying(253)") + .HasColumnName("host"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("kind"); + + b.Property("LastVerificationError") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("last_verification_error"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("VerificationAttempts") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("verification_attempts"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("verified_at"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_tenant_domains"); + + b.HasIndex("Host") + .IsUnique() + .HasDatabaseName("ux_tenant_domains_host"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_tenant_domains_tenant_id"); + + b.ToTable("tenant_domains", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantFeatureFlag", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Value") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("value"); + + b.HasKey("TenantId", "Key") + .HasName("pk_tenant_feature_flags"); + + b.ToTable("tenant_feature_flags", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantLocale", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Locale") + .HasMaxLength(35) + .HasColumnType("character varying(35)") + .HasColumnName("locale"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("is_default"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_enabled"); + + b.Property("Sort") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((short)0) + .HasColumnName("sort"); + + b.HasKey("TenantId", "Locale") + .HasName("pk_tenant_locales"); + + b.ToTable("tenant_locales", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Tenancy.Domain.TenantSetting", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Value") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("value"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_tenant_settings"); + + b.HasIndex("TenantId", "OrganizationId") + .HasDatabaseName("ix_tenant_settings_tenant_id_organization_id"); + + b.HasIndex("TenantId", "OrganizationId", "Key") + .IsUnique() + .HasDatabaseName("ux_tenant_settings_tenant_id_organization_id_key"); + + b.ToTable("tenant_settings", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/SnakeCaseNaming.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/SnakeCaseNaming.cs new file mode 100644 index 00000000..901d35eb --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/SnakeCaseNaming.cs @@ -0,0 +1,128 @@ +using System.Globalization; +using System.Text; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; + +/// +/// Rewrites every table, column, key, index and constraint name the model +/// produces into snake_case. +/// +/// +/// +/// Why this is not the `EFCore.NamingConventions` package. Measured: the +/// only version compatible with EF Core 10 is 10.0.1, and it requires +/// Microsoft.EntityFrameworkCore >= 10.0.1 while this repository pins +/// 10.0.0 in central package management — taking it means bumping the ORM +/// solution-wide, which is a larger change than a naming convention should make. +/// This is forty lines with no dependency and no version coupling. +/// +/// +/// Why a convention and not `HasColumnName` per property. Every RLS policy +/// predicate, every GRANT and every index name in +/// Database Standards +/// is written against snake_case identifiers. Naming sixty columns by hand means a +/// forgotten one is silently PascalCase — a column the policy does not +/// mention and the grant does not cover. Every_Mapped_Identifier_Is_Snake_Case +/// is what makes the omission impossible rather than unlikely. +/// +/// +internal static class SnakeCaseNaming +{ + public static void ApplySnakeCaseNames(this ModelBuilder modelBuilder) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + // An explicit ToTable in a configuration wins: the table name is set + // before this runs, and converting an already-snake_case name is a + // no-op, so there is no ordering hazard either way. + var tableName = entity.GetTableName(); + if (tableName is not null) + { + entity.SetTableName(ToSnakeCase(tableName)); + } + + foreach (var property in entity.GetProperties()) + { + var columnName = property.GetColumnName(); + if (columnName is not null) + { + property.SetColumnName(ToSnakeCase(columnName)); + } + } + + foreach (var key in entity.GetKeys()) + { + var name = key.GetName(); + if (name is not null) + { + key.SetName(ToSnakeCase(name)); + } + } + + foreach (var foreignKey in entity.GetForeignKeys()) + { + var name = foreignKey.GetConstraintName(); + if (name is not null) + { + foreignKey.SetConstraintName(ToSnakeCase(name)); + } + } + + foreach (var index in entity.GetIndexes()) + { + var name = index.GetDatabaseName(); + if (name is not null) + { + index.SetDatabaseName(ToSnakeCase(name)); + } + } + } + } + + /// + /// TenantIdtenant_id, IsPubliclyLiveis_publicly_live, + /// PK_Tenantspk_tenants. + /// + /// + /// A boundary is inserted before an upper-case letter that follows a + /// lower-case letter or digit, and before the last upper-case letter of a run + /// that is followed by a lower-case one — so HTTPStatus becomes + /// http_status rather than h_t_t_p_status. An existing + /// underscore is left alone, which is what makes the function idempotent and + /// therefore safe to apply to a name a configuration already set. + /// + internal static string ToSnakeCase(string name) + { + if (string.IsNullOrEmpty(name)) + { + return name; + } + + var builder = new StringBuilder(name.Length + 8); + + for (var index = 0; index < name.Length; index++) + { + var current = name[index]; + + if (char.IsUpper(current) && index > 0) + { + var previous = name[index - 1]; + var startsNewWord = + !char.IsUpper(previous) && previous != '_' + || (index + 1 < name.Length && char.IsLower(name[index + 1])); + + if (startsNewWord && builder.Length > 0 && builder[^1] != '_') + { + builder.Append('_'); + } + } + + builder.Append(char.ToLower(current, CultureInfo.InvariantCulture)); + } + + return builder.ToString(); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs new file mode 100644 index 00000000..cfec26b3 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContext.cs @@ -0,0 +1,63 @@ +using LearnStack.Modules.Tenancy.Domain; +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; + +/// +/// The Tenancy module's unit of persistence — one DbContext per module, +/// per ADR-0002. +/// +/// +/// +/// It does not own a connection. Per +/// ADR-0040 +/// the connection belongs to IUnitOfWork, one per scope, and every module +/// context is built on it. A context that opened its own would never see the +/// SET LOCAL app.tenant_id issued on the ambient one, and every read +/// through it would return zero rows under the corrected policy — silently, +/// because a policy that filters everything is indistinguishable from a table +/// with no matching data. Packet 6 step 6 completes that seam; until then the +/// registration takes a connection from DI rather than a connection string, so +/// there is one call site to change and not many. +/// +/// +/// No global query filters here yet. The tenant and organization filters +/// are Packet 7's, with TenantResolverMiddleware and the request-scoped +/// ITenantContext they read. Between the two packets no tenant-owned table +/// is read on a request path, and with the policies live and app.tenant_id +/// unset every predicate evaluates to NULL and every query correctly +/// returns zero rows — fail-closed by construction rather than by a filter that +/// does not exist yet +/// (ADR-0003 +/// Amendment 3). +/// +/// +public sealed class TenancyDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Tenants => Set(); + + public DbSet Organizations => Set(); + + public DbSet TenantDomains => Set(); + + public DbSet TenantLocales => Set(); + + public DbSet TenantSettings => Set(); + + public DbSet TenantFeatureFlags => Set(); + + public DbSet PlatformEntitlements => Set(); + + public DbSet PlatformHostMappings => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + + modelBuilder.ApplyConfigurationsFromAssembly(typeof(TenancyDbContext).Assembly); + + // Last, so it also rewrites anything the configurations named explicitly. + // ToSnakeCase is idempotent, so a name already in snake_case is unchanged. + modelBuilder.ApplySnakeCaseNames(); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs new file mode 100644 index 00000000..720c2c92 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs @@ -0,0 +1,84 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; + +/// +/// Builds a for dotnet ef at design time. +/// +/// +/// +/// This exists so the tooling never resolves the runtime connection string. +/// Without it, dotnet ef --startup-project backend/src/LearnStack.Api builds +/// the API's service provider and takes ConnectionStrings:Default — the +/// learnstack_app role, which holds USAGE but not CREATE on +/// schema public. The migration then fails with +/// permission denied for schema public, and the obvious local fix for that +/// error — granting the runtime role CREATE, or making it the owner — is +/// exactly the arrangement FORCE ROW LEVEL SECURITY exists to defeat +/// (Database Standards +/// § Database roles). +/// +/// +/// It reads ConnectionStrings__Migration from the environment and nothing +/// else. No appsettings, no user secrets, no fallback to +/// ConnectionStrings__Default: a fallback is how the wrong role gets used +/// by accident, and the whole point of the four-role split is that using the +/// wrong one is loud. make migrate is the sanctioned carrier and supplies +/// the value. +/// +/// +/// Design-time only. Nothing at runtime constructs a context this way — ADR-0040 +/// has every module context built on the connection IUnitOfWork owns. +/// +/// +public sealed class TenancyDbContextFactory : IDesignTimeDbContextFactory +{ + private const string ConnectionStringVariable = "ConnectionStrings__Migration"; + + public TenancyDbContext CreateDbContext(string[] args) + { + // `dotnet ef --connection` arrives as an argument rather than an + // environment variable, and it must win: `make migrate` passes it, and a + // developer overriding one run should not have to edit their .env. + var connectionString = + ReadConnectionArgument(args) + ?? Environment.GetEnvironmentVariable(ConnectionStringVariable); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + $"{ConnectionStringVariable} is not set and no --connection was passed. " + + "Migrations run as learnstack_migration, which owns every table; the value " + + "is in .env.example and `make migrate` is its sanctioned carrier. " + + "Do not point this at ConnectionStrings__Default — that role cannot CREATE " + + "in schema public, and granting it that is the ownership mistake the " + + "four-role split exists to prevent."); + } + + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString, npgsql => + npgsql.MigrationsHistoryTable("__ef_migrations_history_tenancy")) + .Options; + + return new TenancyDbContext(options); + } + + private static string? ReadConnectionArgument(string[] args) + { + if (args is null) + { + return null; + } + + for (var index = 0; index < args.Length - 1; index++) + { + if (string.Equals(args[index], "--connection", StringComparison.Ordinal)) + { + return args[index + 1]; + } + } + + return null; + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs index 189cae66..779c206b 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs @@ -428,7 +428,15 @@ public void Modules_Do_Not_Inject_IEventBus_Directly() var assembly = TryLoadAssembly(name); if (assembly is null) continue; + // Compiler- and generator-emitted types are excluded, and the reason + // is specific rather than hygienic: Vogen emits a nested TypeConverter + // per value object, and TypeConverter's ConvertFrom takes an + // ITypeDescriptorContext — which implements IServiceProvider. The + // service-locator clause caught every strongly-typed id the moment the + // first module declared one. A generated type cannot inject anything + // the author chose, so it is not what this rule is aimed at. var offenders = assembly.GetTypes() + .Where(t => !IsGenerated(t)) .Where(UsesForbiddenEventBusAccess) .Select(t => t.FullName) .ToList(); @@ -441,15 +449,46 @@ public void Modules_Do_Not_Inject_IEventBus_Directly() } } + /// + /// True for a type the compiler or a source generator emitted, including one + /// nested inside a hand-written type. + /// + /// + /// Walks the declaring chain because Vogen's EfCoreValueConverter and + /// <Name>TypeConverter are nested inside the value object, and the + /// attribute sits on the nested type rather than on the outer one. + /// + private static bool IsGenerated(Type type) + { + for (var current = type; current is not null; current = current.DeclaringType) + { + if (current.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), inherit: false) + || current.IsDefined(typeof(System.CodeDom.Compiler.GeneratedCodeAttribute), inherit: false)) + { + return true; + } + } + + return false; + } + private static bool UsesForbiddenEventBusAccess(Type type) { var bus = typeof(LearnStack.SharedKernel.Messaging.IEventBus); var serviceProvider = typeof(IServiceProvider); var forbidden = new[] { bus, serviceProvider }; + // DeclaredOnly, and it is load-bearing rather than an optimisation. Without + // it the check reads INHERITED members too, and DbContext implements + // IInfrastructure — so every module's DbContext was + // flagged the moment the first one existed, for something EF declares and + // the module author never wrote. The question this rule asks is what THIS + // type does; what a base type exposes is the base type's business, and + // DbContext is not a module type. const BindingFlags members = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public - | BindingFlags.NonPublic; + | BindingFlags.NonPublic + | BindingFlags.DeclaredOnly; return type.GetConstructors(members).Any(constructor => constructor.GetParameters().Any(parameter => diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs new file mode 100644 index 00000000..22a696b5 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs @@ -0,0 +1,361 @@ +using FluentAssertions; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// The tenancy schema, its Row Level Security policies and its grants, asserted +/// against a real PostgreSQL with the four roles provisioned. +/// +/// +/// +/// Every case connects as learnstack_app. A test that connected as +/// the owner — or as either BYPASSRLS role — would pass with every policy +/// inert and prove nothing, which is the failure mode +/// ADR-0003 +/// Amendment 3 names by hand. The one exception is the structural sweep, +/// which reads catalogue tables rather than data. +/// +/// +/// These are the four cases that amendment lists as the minimum, plus the two +/// this schema adds: the organization-id immutability trigger, and the owner +/// being denied on the platform-scoped table. +/// +/// +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class TenancySchemaTests : IClassFixture +{ + private static readonly Guid TenantA = Guid.Parse("11111111-1111-7111-8111-111111111111"); + private static readonly Guid TenantB = Guid.Parse("22222222-2222-7222-8222-222222222222"); + private static readonly Guid OrgA = Guid.Parse("aaaaaaaa-1111-7111-8111-111111111111"); + private static readonly Guid SettingA = Guid.Parse("11111111-aaaa-7111-8111-111111111111"); + + private readonly TenancySchemaFixture _schema; + + public TenancySchemaTests(TenancySchemaFixture schema) => _schema = schema; + + [Fact] + public async Task EveryTableEnablesAndForcesRowLevelSecurity() + { + // No exception list, deliberately: a table that needed one would be a + // table nobody had classified, and the three classes in Database + // Standards § Table classes cover all eight. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); + await using var command = new NpgsqlCommand( + """ + SELECT relname FROM pg_class + WHERE relname = ANY(@tables) AND NOT (relrowsecurity AND relforcerowsecurity) + """, (NpgsqlConnection)connection); + command.Parameters.AddWithValue("tables", TenancySchemaFixture.Tables); + + var unprotected = new List(); + await using (var reader = await command.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + unprotected.Add(reader.GetString(0)); + } + } + + unprotected.Should().BeEmpty("ENABLE without FORCE lets the owner bypass its own policies"); + } + + [Fact] + public async Task NoTenantOwnedTableCarriesASecondPermissivePolicy() + { + // The defect ADR-0003 Amendment 3 corrects. CREATE POLICY is permissive by + // default and PostgreSQL combines permissive policies with OR, so a second + // one WIDENS access — it made every tenant-wide row visible to every + // tenant. Only platform_host_to_tenant carries several, and they are + // per-command (SELECT/INSERT/UPDATE/DELETE), which do not combine. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); + await using var command = new NpgsqlCommand( + """ + SELECT tablename, count(*) + FROM pg_policies + WHERE schemaname = 'public' + AND permissive = 'PERMISSIVE' + AND tablename <> 'platform_host_to_tenant' + GROUP BY tablename HAVING count(*) > 1 + """, (NpgsqlConnection)connection); + + await using var reader = await command.ExecuteReaderAsync(); + + (await reader.ReadAsync()).Should().BeFalse( + "a second permissive policy is OR-ed with the first and widens access"); + } + + [Fact] + public async Task WithNoTenantContextEveryTenantOwnedTableReturnsZeroRows() + { + // Fail-closed, and it is the NULLIF that delivers it: an unset dotted GUC + // reads as the empty string on a pooled connection, and ''::uuid RAISES + // rather than filtering. NULLIF makes it NULL, and a NULL predicate is + // false for USING and WITH CHECK alike. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var command = new NpgsqlCommand("SELECT count(*) FROM tenants", (NpgsqlConnection)connection); + + (await command.ExecuteScalarAsync()).Should().Be(0L); + } + + [Fact] + public async Task ATenantSeesItsOwnRowsAndOnlyThose() + { + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SetTenantAsync(connection, transaction, TenantA); + + await using var command = new NpgsqlCommand( + "SELECT (SELECT count(*) FROM tenants), (SELECT count(*) FROM organizations)", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + + await using var reader = await command.ExecuteReaderAsync(); + await reader.ReadAsync(); + + reader.GetInt64(0).Should().Be(1); + reader.GetInt64(1).Should().Be(1); + } + + [Fact] + public async Task ATenantWideRowOfOneTenantIsInvisibleToAnother() + { + // THE case the superseded template leaked. Its two permissive policies + // were OR-ed, so a row with organization_id IS NULL satisfied the + // organization half on its own — visible to every tenant. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SetTenantAsync(connection, transaction, TenantB); + + await using var command = new NpgsqlCommand( + "SELECT count(*) FROM tenant_settings", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + + (await command.ExecuteScalarAsync()).Should().Be(0L, + "tenant A's tenant-wide setting must not be visible to tenant B"); + } + + [Fact] + public async Task AWriteNamingAForeignTenantIsRefused() + { + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SetTenantAsync(connection, transaction, TenantB); + + await using var command = new NpgsqlCommand( + """ + INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) + VALUES (gen_random_uuid(), @foreign, 'sneak', 'Sneak', 'Active', now(), @actor, 0) + """, (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + command.Parameters.AddWithValue("foreign", TenantA); + command.Parameters.AddWithValue("actor", Guid.Parse("00000000-0000-7000-8000-000000000001")); + + var act = async () => await command.ExecuteNonQueryAsync(); + + (await act.Should().ThrowAsync()) + .Which.SqlState.Should().Be("42501", "WITH CHECK refuses a row outside the caller's tenant"); + } + + [Fact] + public async Task OrganizationIdIsImmutableAfterInsert() + { + // Tenant-wide to org-scoped: the NULL -> value move, which `<>` would miss + // because `<>` is NULL when either side is null. The restrictive UPDATE + // guard does not cover it either — it admits the row when the NEW + // organization_id is the caller's own, which is exactly this move. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SetTenantAsync(connection, transaction, TenantA); + await SetSettingAsync(connection, transaction, "app.organization_id", OrgA.ToString()); + + await using var command = new NpgsqlCommand( + "UPDATE tenant_settings SET organization_id = @org WHERE id = @id", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + command.Parameters.AddWithValue("org", OrgA); + command.Parameters.AddWithValue("id", SettingA); + + var act = async () => await command.ExecuteNonQueryAsync(); + + (await act.Should().ThrowAsync()) + .Which.MessageText.Should().Contain("immutable after insert"); + } + + [Fact] + public async Task TheOwnerIsDeniedOnThePlatformScopedTable() + { + // platform_host_to_tenant's policies are role-qualified TO learnstack_app, + // so NO policy applies to the owner — and under FORCE that is a denial + // rather than a bypass. Rows arrive through learnstack_app under tenant + // context, or through learnstack_platform. Stated as a test because it + // reads like a mistake and is the design. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); + await using var command = new NpgsqlCommand( + "SELECT count(*) FROM platform_host_to_tenant", (NpgsqlConnection)connection); + + (await command.ExecuteScalarAsync()).Should().Be(0L); + } + + [Fact] + public async Task EveryMappedIdentifierIsSnakeCase() + { + // Every policy predicate, every GRANT and every index name in Database + // Standards is written against snake_case identifiers, so one PascalCase + // column is a column the policy does not mention and the grant does not + // cover. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); + await using var command = new NpgsqlCommand( + """ + SELECT table_name || '.' || column_name + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = ANY(@tables) AND column_name <> lower(column_name) + """, (NpgsqlConnection)connection); + command.Parameters.AddWithValue("tables", TenancySchemaFixture.Tables); + + var offenders = new List(); + await using (var reader = await command.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + offenders.Add(reader.GetString(0)); + } + } + + offenders.Should().BeEmpty(); + } + + [Fact] + public async Task TheApplicationRoleHoldsExactlyTheGrantsTheMatrixNames() + { + // There is no ALTER DEFAULT PRIVILEGES, so every grant is one the migration + // wrote. Asserted table by table because the matrix is the whole of the + // bound on the bypass roles, and a widened grant is invisible otherwise. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); + await using var command = new NpgsqlCommand( + """ + SELECT table_name, string_agg(privilege_type, ',' ORDER BY privilege_type) + FROM information_schema.role_table_grants + WHERE grantee = 'learnstack_app' AND table_schema = 'public' + GROUP BY table_name ORDER BY table_name + """, (NpgsqlConnection)connection); + + var grants = new Dictionary(StringComparer.Ordinal); + await using (var reader = await command.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + grants[reader.GetString(0)] = reader.GetString(1); + } + } + + grants.Should().BeEquivalentTo(new Dictionary(StringComparer.Ordinal) + { + ["tenants"] = "INSERT,SELECT,UPDATE", + ["organizations"] = "DELETE,INSERT,SELECT,UPDATE", + ["tenant_domains"] = "DELETE,INSERT,SELECT,UPDATE", + ["tenant_locales"] = "DELETE,INSERT,SELECT,UPDATE", + ["tenant_settings"] = "DELETE,INSERT,SELECT,UPDATE", + ["tenant_feature_flags"] = "DELETE,INSERT,SELECT,UPDATE", + ["platform_entitlement_cache"] = "INSERT,SELECT,UPDATE", + ["platform_host_to_tenant"] = "DELETE,INSERT,SELECT,UPDATE", + }); + } + + private static Task SetTenantAsync( + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + Guid tenantId) + => SetSettingAsync(connection, transaction, "app.tenant_id", tenantId.ToString()); + + private static async Task SetSettingAsync( + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + string name, + string value) + { + // set_config(..., true) rather than SET LOCAL: PostgreSQL's SET takes no + // bind parameter — `SET LOCAL x = $1` is a syntax error, measured — and the + // third argument `true` is what makes it transaction-local. The transaction + // is passed explicitly because that locality is the whole point: a setting + // applied to the wrong transaction is applied to nothing. + await using var command = new NpgsqlCommand( + "SELECT set_config(@name, @value, true)", + (NpgsqlConnection)connection, + (NpgsqlTransaction)transaction); + command.Parameters.AddWithValue("name", name); + command.Parameters.AddWithValue("value", value); + await command.ExecuteNonQueryAsync(); + } +} + +/// +/// Applies the tenancy migration once and seeds the two tenants the isolation +/// cases compare. +/// +/// +/// The seed runs as learnstack_migration inside a transaction that sets +/// app.tenant_id per tenant — the only way to insert, since every table's +/// WITH CHECK is live from the moment the migration finishes. +/// +public sealed class TenancySchemaFixture : IAsyncLifetime +{ + internal static readonly string[] Tables = + [ + "tenants", "organizations", "tenant_domains", "tenant_locales", + "tenant_settings", "tenant_feature_flags", + "platform_entitlement_cache", "platform_host_to_tenant", + ]; + + public PostgresFixture Postgres { get; } = new(); + + public async Task InitializeAsync() + { + await Postgres.InitializeAsync(); + + var options = new DbContextOptionsBuilder() + .UseNpgsql(Postgres.MigrationConnectionString, npgsql => + npgsql.MigrationsHistoryTable("__ef_migrations_history_tenancy")) + .Options; + + await using (var context = new TenancyDbContext(options)) + { + await context.Database.MigrateAsync(); + } + + await SeedAsync(); + } + + public async Task DisposeAsync() => await Postgres.DisposeAsync(); + + private async Task SeedAsync() + { + await using var connection = await PostgresFixture.OpenAsync(Postgres.MigrationConnectionString); + await using var command = new NpgsqlCommand( + """ + BEGIN; + SET LOCAL app.tenant_id = '11111111-1111-7111-8111-111111111111'; + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('11111111-1111-7111-8111-111111111111','alpha','Alpha','Trial', now(), + '00000000-0000-7000-8000-000000000001', 0); + INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('aaaaaaaa-1111-7111-8111-111111111111','11111111-1111-7111-8111-111111111111', + 'main','Main','Active', now(), '00000000-0000-7000-8000-000000000001', 0); + UPDATE tenants SET default_organization_id = 'aaaaaaaa-1111-7111-8111-111111111111' + WHERE id = '11111111-1111-7111-8111-111111111111'; + INSERT INTO tenant_settings (id, tenant_id, organization_id, key, value, created_at, created_by, row_version) + VALUES ('11111111-aaaa-7111-8111-111111111111','11111111-1111-7111-8111-111111111111', + NULL, 'tz', '"Europe/Istanbul"', now(), '00000000-0000-7000-8000-000000000001', 0); + COMMIT; + + BEGIN; + SET LOCAL app.tenant_id = '22222222-2222-7222-8222-222222222222'; + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('22222222-2222-7222-8222-222222222222','beta','Beta','Trial', now(), + '00000000-0000-7000-8000-000000000001', 0); + COMMIT; + """, (NpgsqlConnection)connection); + + await command.ExecuteNonQueryAsync(); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj b/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj index 356c8e18..a1df9e5e 100644 --- a/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj +++ b/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj @@ -15,6 +15,12 @@ + + From 6cc3b9bd245e8f7d431bd3b17cd978ee51d4d518 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 11:45:01 +0300 Subject: [PATCH 13/35] docs(modules): the Tenancy module spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standards 13 requires a spec under docs/modules// before a module is "done", and this is the first module in the repository — so it is also the first directory. Standards 19 and 18 name two of its files specifically (permissions.md, audit.md), so the matrices live there rather than inline. All ten required sections, and the ones that would be easy to fake are the ones that matter here: - The integration-event catalogue lists five topics Tenancy does NOT publish yet, each against its owning phase. Listing them is the alternative to discovering the same topic name twice. - The permission matrix registers nothing today, because Packet 6 ships no handler. `Tenant` has no `delete` action at all: deprovisioning has no owning phase, and Standards 05 records that the grant widening it needs is an ADR's to make. - The audit matrix classifies operations that do not exist, which is the point — it is the MUST floor a later packet may narrow for SHOULD/MAY and never for MUST. It also notes the trap: a feature flag gating a BILLED capability is plan-level and belongs in the entitlement projection, so a SHOULD here never covers a change that should have been MUST elsewhere. - Risks names five things the schema does not enforce, including two the review rounds are likely to find anyway: nothing stops two default locales per tenant, and tenant_domains.host can disagree with platform_host_to_tenant.host. Every diagram carries a text fallback, per CLAUDE.md's rule for renderers without Mermaid. Also corrects a link I got wrong in Organization.cs: Phase 06 is phase-06-renderer-admin-studio.md, not phase-06-admin-portal.md. Co-Authored-By: Claude Opus 5 (1M context) --- .../Organization.cs | 2 +- docs/modules/tenancy/README.md | 268 ++++++++++++++++++ docs/modules/tenancy/audit.md | 27 ++ docs/modules/tenancy/permissions.md | 22 ++ 4 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 docs/modules/tenancy/README.md create mode 100644 docs/modules/tenancy/audit.md create mode 100644 docs/modules/tenancy/permissions.md diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs index e40ef33e..bdac7b2f 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs @@ -26,7 +26,7 @@ namespace LearnStack.Modules.Tenancy.Domain; /// /// Branding is not here. ADR-0017's sample carries an /// OrganizationBranding? override; the value object and the token merge -/// belong to Phase 06, +/// belong to Phase 06, /// so the column arrives with them rather than as an unused jsonb nobody /// writes. /// diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md new file mode 100644 index 00000000..25ffeac7 --- /dev/null +++ b/docs/modules/tenancy/README.md @@ -0,0 +1,268 @@ +# Module Spec — Tenancy + +**Status:** Design stable, partially implemented (Phase 02a Packet 6 shipped the +schema; commands, resolution and the isolation suite are Packet 7). + +The first module spec in the repository, per +[Documentation Standards § Per-Module Specifications](../../standards/13-documentation.md). + +## Overview + +Tenancy owns **who a request belongs to** and nothing about what they do with it. + +**It owns:** + +- The `Tenant` aggregate — the root every other tenant-owned row keys on. +- The `Organization` aggregate — a sub-unit within a tenant + ([ADR-0017](../../decisions/0017-tenant-organization-hierarchy.md)). Declared + here and nowhere else; ADR-0017's original sample placed it in Identity and + Amendment 2 moved it. +- `TenantDomain` — a host a tenant claims, and its verification lifecycle. +- `TenantSetting` — non-translated configuration, optionally overridden per + organization. +- `TenantLocale` — the locales a tenant publishes in + ([ADR-0008](../../decisions/0008-localization-schema.md)). +- `TenantFeatureFlag` — the tenant's own switches. +- `platform_host_to_tenant` — the host → tenant resolution index, read *before* + any tenant context exists. +- `platform_entitlement_cache` — the durable projection of a tenant's plan. + +**It does not own:** + +- **Users, roles or sessions.** Identity does, from + [Phase 03](../../roadmap/phase-03-identity-admin.md). Tenancy holds + `UserId` by value in audit columns and never resolves a person. +- **Plan definitions or billing.** The Hub does + ([ADR-0019](../../decisions/0019-learnstack-hub.md)). Tenancy stores the + *projection* of an entitlement, written only through + `IEntitlementProvider.RefreshAsync`, and never calls the Hub to read it. +- **Certificate material.** It moves by secret-store replication and is + referenced by path; `tenant_domains` carries verification state and no keys. +- **Branding tokens.** `OrganizationBranding` and the token merge are + [Phase 06](../../roadmap/phase-06-renderer-admin-studio.md); the column arrives with + them rather than as an unused `jsonb` nobody writes. +- **Any domain-specific shape.** CEFR levels, asana catalogs, kyu/dan ranks and + every other vertical concept are tenant customization data + ([ADR-0018](../../decisions/0018-tenant-driven-customization-model.md)), not + columns here. + +## Entity-relationship diagram + +Aggregate roots are `Tenant` and `Organization`. `TenantDomain`, `TenantSetting`, +`TenantLocale` and `TenantFeatureFlag` are entities inside the Tenant aggregate. +`PlatformHostMapping` and `PlatformEntitlement` are projections rather than +aggregates — nothing in this module mutates them through a root. + +```mermaid +erDiagram + TENANTS ||--o{ ORGANIZATIONS : "has" + TENANTS ||--o| ORGANIZATIONS : "default_organization_id" + TENANTS ||--o{ TENANT_DOMAINS : "claims" + TENANTS ||--o{ TENANT_LOCALES : "publishes in" + TENANTS ||--o{ TENANT_SETTINGS : "configures" + TENANTS ||--o{ TENANT_FEATURE_FLAGS : "switches" + TENANTS ||--o| PLATFORM_ENTITLEMENT_CACHE : "is entitled by" + TENANTS ||--o{ PLATFORM_HOST_TO_TENANT : "is reached at" + ORGANIZATIONS ||--o{ TENANT_SETTINGS : "overrides" + ORGANIZATIONS ||--o{ PLATFORM_HOST_TO_TENANT : "may serve" + ORGANIZATIONS ||--o| ORGANIZATIONS : "reporting_parent_id" +``` + +Text fallback, for renderers without Mermaid: + +- `tenants` is the root. Its `id` **is** the tenant id — there is no `tenant_id` + column, which is why its RLS policy keys on `id`. +- `organizations.tenant_id → tenants.id`, single-column by the one written + exception in [Database Standards § Foreign keys](../../standards/05-database.md): + the composite form is not expressible against a self-keyed parent, and it is + unnecessary because the referencing column *is* the tenant id. +- `tenants.default_organization_id` → `organizations (tenant_id, id)`, + **composite**, and nullable only inside the provisioning transaction. +- Every other foreign key into `organizations` is composite on `tenant_id`. +- `organizations.reporting_parent_id` is a self-reference for **reporting only**. + It is not an isolation boundary: nothing resolves through it and no policy + reads it. The hierarchy stays two levels. + +## State diagrams + +Two entities have a non-trivial lifecycle. + +```mermaid +stateDiagram-v2 + direction LR + [*] --> Trial : provisioned + Trial --> Active : first payment, or a plan needing none + Active --> Suspended : billing failure, policy breach + Suspended --> Active : resolved + Active --> Archived : ended + Suspended --> Archived : ended + Archived --> [*] : retained for audit, never served +``` + +Tenant: `Trial → Active → Suspended ⇄ Active → Archived`. `Archived` is terminal +for serving; rows are retained for audit and retention obligations. + +```mermaid +stateDiagram-v2 + direction LR + [*] --> Requested : custom domain submitted + [*] --> Verified : platform subdomain (verified by construction) + Requested --> Verifying : check started + Verifying --> Verified : DNS proof accepted + Verifying --> Failed : proof rejected + Failed --> Verifying : retried +``` + +TenantDomain: a `Subdomain` is created already `Verified` because the platform +controls the zone; only a `Custom` domain travels the whole path. **A verified +row does not serve traffic on its own** — a corresponding +`platform_host_to_tenant` row with `is_publicly_live` does. + +## Sequence diagrams + +### Primary write: provisioning a tenant + +```mermaid +sequenceDiagram + participant R as Registry (Hub / config / fixture) + participant H as Handler + participant U as IUnitOfWork + participant D as TenancyDbContext + participant P as PostgreSQL + + R->>H: tenant id, slug, display name + H->>U: BeginTransactionAsync + U->>P: BEGIN + H->>U: SetTenantContextAsync(id) + U->>P: SET LOCAL app.tenant_id = + H->>D: INSERT tenants + D->>P: WITH CHECK passes — app.tenant_id already equals id + H->>D: INSERT organizations (default) + H->>D: UPDATE tenants SET default_organization_id + H->>U: CommitAsync + U->>P: COMMIT +``` + +Three statements, one transaction. The tenant id is **never minted in the +handler**: the registry assigns it and the transaction sets `app.tenant_id` to +that value *before* the insert, so the self-keyed policy's `WITH CHECK` passes. A +handler that generated its own could not satisfy its own policy. The +`default_organization_id` update is separate because the composite foreign key +has nothing to reference until the organization exists; `MATCH SIMPLE` skips the +check while the column is null, which is what makes the ordering legal. + +### Primary integration-event flow: host mapping changed + +```mermaid +sequenceDiagram + participant Hub as Hub (02c) + participant API as /api/internal/tenants/{id}/host-mappings + participant D as TenancyDbContext + participant O as outbox_messages + participant C as CachedHostToTenantResolver + + Hub->>API: PUT host mappings + API->>D: upsert platform_host_to_tenant + API->>O: enqueue learnstack.hub.custom-domain.activated + Note over O,C: same transaction — the outbox IS the boundary + O-->>C: invalidate the resolver cache entry +``` + +`IHostToTenantResolver` **never calls the Hub**: an anonymous page load must not +depend on a control plane being reachable +([ADR-0034](../../decisions/0034-hub-contract-surface-invariant.md)). The +resolver reads `platform_host_to_tenant` and nothing else. + +## Component diagram + +```mermaid +flowchart LR + subgraph Tenancy + DOM[Domain
Tenant, Organization] + APP[Application] + INF[Infrastructure
TenancyDbContext] + end + SK[SharedKernel
TenantId, OrganizationId, IUnitOfWork] + PG[(PostgreSQL
8 tables, RLS)] + HUB[Hub adapters
IEntitlementProvider, IHubTenantSync] + OTHER[Other modules] + + DOM --> SK + APP --> DOM + INF --> APP + INF --> PG + HUB -.-> APP + OTHER -.->|application contract only| APP +``` + +Other modules reach Tenancy **only** through an application contract in +`LearnStack.Modules.Tenancy.Application.Contracts` — never a navigation property, +never a cross-module join +([ADR-0010](../../decisions/0010-cross-module-communication.md)). They hold +`TenantId` and `OrganizationId` by value from `LearnStack.SharedKernel`. + +## Integration-event catalogue + +**Tenancy publishes none yet.** The events below are declared by the phases that +build their producers; listing them here with their owning phase is the +alternative to discovering the topic name twice. + +| Topic | Payload | Publisher | Consumers | Phase | +|---|---|---|---|---| +| `learnstack.tenancy.tenant` | tenant created / status changed | Tenancy | Identity, Audit | 02b | +| `learnstack.tenancy.organization` | organization created / archived | Tenancy | Identity, Education | 02b | +| `learnstack.tenancy.settings` | settings changed — the eager cache invalidation | Tenancy | every settings reader | 02b | +| `learnstack.hub.entitlement` | entitlement projection refreshed | Hub adapter | entitlement cache readers | 02c | +| `learnstack.hub.custom-domain.activated` / `.deactivated` | host mapping changed | Hub adapter | `CachedHostToTenantResolver` | 02c | + +Topic names follow `learnstack.{module}.{aggregate}`; +`Integration_Event_TopicNames_FollowConvention` is the authority for the pattern +and this table does not restate it. + +## Permission matrix + +In [permissions.md](permissions.md), the file +[Permission Standards](../../standards/19-permissions.md) names. + +## Audit coverage matrix + +In [audit.md](audit.md), the file +[Audit Coverage](../../standards/18-audit-coverage.md) names. + +## Performance budget + +| Path | Budget | Why this number | +|---|---|---| +| Host → tenant resolution (cache hit) | **< 1 ms** | On every anonymous page load, before anything else can start | +| Host → tenant resolution (cache miss) | **< 15 ms** p95 | One indexed single-row read in its own short transaction | +| Entitlement projection read (L1 hit) | **< 1 ms** | Read on every feature check | +| Tenant provisioning (3 statements) | **< 100 ms** p95 | Interactive but rare | +| Settings read for a request | **< 5 ms** p95 | Cached; a miss is one indexed read | + +The two resolution numbers are the load-bearing ones: they sit in front of every +request and are the only Tenancy work an anonymous visitor pays for. + +## Risks and open questions + +- **`app.scope` has no carrier.** `ITenantContext` exposes no scope member, so + nothing sets `app.scope = 'tenant'` and the cross-organization read hatch on + `tenant_settings` is currently unreachable. That is the correct default; + [Packet 7](../../roadmap/phase-02a-kernel-tenancy.md) decides how the flag + arrives ([ADR-0040 Amendment 1](../../decisions/0040-ambient-unit-of-work.md)). +- **No query filters yet.** The EF tenant and organization filters land in Packet + 7 with `TenantResolverMiddleware`. Between the packets nothing reads a + tenant-owned table on a request path, and with `app.tenant_id` unset every + policy predicate is `NULL` and every query returns zero rows — fail-closed by + construction rather than by a filter that does not exist. +- **Two defaults per tenant are possible.** Nothing stops two `tenant_locales` + rows with `is_default = true` for one tenant. A partial unique index would fix + it; whether the invariant belongs in the database or in the aggregate is + Packet 7's call, with the first code that reads it. +- **`tenant_domains.host` and `platform_host_to_tenant.host` can disagree.** They + are separate tables on purpose — one is read under tenant context, the other + before any context exists — but nothing enforces that a verified domain has a + mapping or vice versa. The Hub-side lifecycle that keeps them in step is + [Phase 02c](../../roadmap/phase-02c-hub-foundation.md). +- **Tenant hard-deprovisioning has no owning phase.** Every foreign key is + `ON DELETE RESTRICT`, so the absence is loud rather than silent — a delete + fails instead of cascading through a path nobody designed. diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md new file mode 100644 index 00000000..6a7c1e37 --- /dev/null +++ b/docs/modules/tenancy/audit.md @@ -0,0 +1,27 @@ +# Tenancy — Audit Coverage Matrix + +Per [Audit Coverage](../../standards/18-audit-coverage.md), which names this +file. Part of the [module spec](README.md). + +Per [Audit Coverage](../../standards/18-audit-coverage.md). The operations do not +exist yet; the classification does, and it is the floor a later packet may narrow +for SHOULD/MAY but never for MUST. + +| Resource | Operation | Class | Why | +|---|---|---|---| +| `Tenant` | create | **MUST** | The root of a customer's data; its existence is a contractual fact | +| `Tenant` | status change | **MUST** | Suspension withdraws access; an operator must be able to say who and when | +| `Tenant` | rename | SHOULD | Presentational | +| `Organization` | create / archive | **MUST** | Changes the isolation surface of every org-scoped row | +| `Organization` | rename | SHOULD | Presentational | +| `TenantDomain` | claim / verify / fail | **MUST** | A host change redirects traffic; a wrongly verified domain serves one tenant's content at another's address | +| `TenantSetting` | write / delete | SHOULD | Configuration, per key; a tenant `AuditConfig` may narrow this | +| `TenantLocale` | write | SHOULD | Configuration | +| `TenantFeatureFlag` | write | SHOULD | Configuration, but see the note | +| `platform_entitlement_cache` | refresh | **MUST** | Changes what the tenant may do; written only by `IEntitlementProvider.RefreshAsync` | +| `platform_host_to_tenant` | write / delete | **MUST** | The resolution index — the row that decides whose data an anonymous request sees | +| any | read under `EnterPlatformAdminScope` | **MUST** (`read-sensitive`) | Cross-tenant access is the one read worth a row | + +A feature flag that gates a **billed** capability is plan-level and belongs in the +entitlement projection, not here — so a SHOULD on this table never covers a +change that should have been MUST elsewhere. diff --git a/docs/modules/tenancy/permissions.md b/docs/modules/tenancy/permissions.md new file mode 100644 index 00000000..43ad59e4 --- /dev/null +++ b/docs/modules/tenancy/permissions.md @@ -0,0 +1,22 @@ +# Tenancy — Permission Matrix + +Per [Permission Standards](../../standards/19-permissions.md), which names this +file. Part of the [module spec](README.md). + +**No permission keys yet** — Packet 6 ships no command or query handler, so there +is nothing to authorize. The matrix below is what Packet 7 registers, in the +`{module}.{resource}.{action}` form with the closed action set of +[Permission Standards](../../standards/19-permissions.md). + +| Resource | read | write | delete | admin | Default role grants | +|----------|:----:|:-----:|:------:|:-----:|---------------------| +| `Tenant` | ✓ | ✓ | – | ✓ | tenant-admin: read+write; platform operator: admin | +| `Organization` | ✓ | ✓ | ✓ | ✓ | tenant-admin: all; org-admin: read+write (own) | +| `TenantDomain` | ✓ | ✓ | ✓ | – | tenant-admin: all | +| `TenantSetting` | ✓ | ✓ | ✓ | – | tenant-admin: all; org-admin: own organization only | +| `TenantLocale` | ✓ | ✓ | ✓ | – | tenant-admin: all | +| `TenantFeatureFlag` | ✓ | ✓ | ✓ | – | tenant-admin: all | + +`Tenant` has no `delete`: deprovisioning has no owning phase, and +[Database Standards § GRANT matrix](../../standards/05-database.md) records that +the widening it needs is an ADR's to make, not a migration's. From 135ffb48af424cf62db757428254f03614f49ee1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 12:10:40 +0300 Subject: [PATCH 14/35] feat(platform): outbox_messages and idempotency_keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packet 6 step 5. The two tables no module owns, in their own migration chain on their own history table. A PlatformDbContext in LearnStack.Infrastructure owns them, not TenancyDbContext: both are written through SharedKernel ports by any module's handler and read by infrastructure belonging to none of them, so putting them in a module's context would make every other module's use of the outbox a dependency on that module — the shape 15-event-and-outbox rules out when it says LearnStack uses a single shared table, not one per module. It maps no entity types, deliberately. An outbox row is enqueued through IOutbox and never updated by application code, and an idempotency claim is one INSERT ... ON CONFLICT that decides five outcomes in a single round trip (ADR-0037 Amendment 2). The context exists to own the migration — that is what needs a model root — and to be the second DbContext ADR-0040's central property requires: several contexts on one connection is not testable with one, and the ADR expected to wait until Phase 03 for it. Measured against a real PostgreSQL with both chains applied: uuidv7() default -> uuid_extract_version = 7 RLS -> enabled AND forced on both other tenant's outbox -> 0 rows as learnstack_app learnstack_app DELETE -> permission denied dispatcher UPDATE grant -> exactly attempts, available_after, last_error, processed_at oversized body -> ck_idempotency_keys_body_size history tables -> __ef_migrations_history_platform and _tenancy, independent The uuidv7 assertion checks the VERSION rather than that the insert succeeded, because gen_random_uuid() would also have succeeded and produced a v4 with none of the index locality ADR-0023 adopted v7 for — and gen_uuid_v7(), which six documents named before this packet, does not exist at all. Application code only ever enqueues: no UPDATE and no DELETE on the outbox for learnstack_app, because a handler that could mark a row processed could make an event vanish. The dispatcher's BYPASSRLS lets it read every tenant's pending rows, and since BYPASSRLS bypasses policies rather than GRANTs, the four-column UPDATE list is the whole of its bound. 784 tests green across four suites, 0 warnings under CI=true, format clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../LearnStack.Infrastructure.csproj | 3 + ...platform_infrastructure_tables.Designer.cs | 29 +++ ...1_create_platform_infrastructure_tables.cs | 148 +++++++++++ .../PlatformDbContextModelSnapshot.cs | 26 ++ .../Persistence/PlatformDbContext.cs | 54 ++++ .../Persistence/PlatformDbContextFactory.cs | 62 +++++ .../Database/PlatformSchemaTests.cs | 245 ++++++++++++++++++ 7 files changed, 567 insertions(+) create mode 100644 backend/src/LearnStack.Infrastructure/Persistence/Migrations/20260828085701_create_platform_infrastructure_tables.Designer.cs create mode 100644 backend/src/LearnStack.Infrastructure/Persistence/Migrations/20260828085701_create_platform_infrastructure_tables.cs create mode 100644 backend/src/LearnStack.Infrastructure/Persistence/Migrations/PlatformDbContextModelSnapshot.cs create mode 100644 backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContext.cs create mode 100644 backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContextFactory.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/PlatformSchemaTests.cs diff --git a/backend/src/LearnStack.Infrastructure/LearnStack.Infrastructure.csproj b/backend/src/LearnStack.Infrastructure/LearnStack.Infrastructure.csproj index d0fe5e68..5207fb00 100644 --- a/backend/src/LearnStack.Infrastructure/LearnStack.Infrastructure.csproj +++ b/backend/src/LearnStack.Infrastructure/LearnStack.Infrastructure.csproj @@ -13,6 +13,9 @@ + + diff --git a/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20260828085701_create_platform_infrastructure_tables.Designer.cs b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20260828085701_create_platform_infrastructure_tables.Designer.cs new file mode 100644 index 00000000..5dd06fb6 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20260828085701_create_platform_infrastructure_tables.Designer.cs @@ -0,0 +1,29 @@ +// +using LearnStack.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LearnStack.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(PlatformDbContext))] + [Migration("20260828085701_create_platform_infrastructure_tables")] + partial class create_platform_infrastructure_tables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20260828085701_create_platform_infrastructure_tables.cs b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20260828085701_create_platform_infrastructure_tables.cs new file mode 100644 index 00000000..e4747e85 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20260828085701_create_platform_infrastructure_tables.cs @@ -0,0 +1,148 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LearnStack.Infrastructure.Persistence.Migrations +{ + /// + public partial class create_platform_infrastructure_tables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Two tables no module owns, transcribed from the canonical DDL in + // docs/standards/05-database.md § Outbox and § Idempotency. Written as + // raw SQL rather than through the model builder because neither is an + // EF entity: an outbox row is enqueued through IOutbox and read by the + // dispatcher, and an idempotency claim is one INSERT ... ON CONFLICT + // that decides five outcomes in a single round trip. Mapping them would + // add a model nothing queries. + + migrationBuilder.Sql(""" + CREATE TABLE outbox_messages ( + id uuid PRIMARY KEY DEFAULT uuidv7(), + occurred_at timestamptz NOT NULL DEFAULT now(), + tenant_id uuid NOT NULL, + -- Deliberately unindexed and absent from the policy: the event's + -- organization is delivery metadata the consumer restores, not a + -- dimension anything filters the outbox by. + organization_id uuid NULL, + -- text, not uuid: the full W3C traceparent, per ADR-0032. + correlation_id text NOT NULL, + causation_id uuid NULL, + actor_user_id uuid NULL, + type text NOT NULL, + topic text NOT NULL, + partition_key text NOT NULL, + payload jsonb NOT NULL, + metadata jsonb NULL, + processed_at timestamptz NULL, + attempts int NOT NULL DEFAULT 0, + last_error text NULL, + available_after timestamptz NOT NULL DEFAULT now() + ); + + -- Partial, because the dispatcher only ever asks for unprocessed + -- rows and the processed set grows without bound until the purge. + CREATE INDEX ix_outbox_messages_pending + ON outbox_messages (available_after) + WHERE processed_at IS NULL; + + CREATE INDEX ix_outbox_messages_tenant_pending + ON outbox_messages (tenant_id, available_after) + WHERE processed_at IS NULL; + """); + + migrationBuilder.Sql(""" + CREATE TABLE idempotency_keys ( + tenant_id uuid NOT NULL, + key text NOT NULL, + fingerprint text NOT NULL, + claim_token uuid NOT NULL, + state text NOT NULL, + status_code int NULL, + content_type text NULL, + headers jsonb NULL, + body bytea NULL, + claimed_at timestamptz NOT NULL DEFAULT now(), + -- ONE expiry column: the 5-minute claim lease while in_flight and + -- the 24-hour retention window once an outcome is recorded, so the + -- claim statement's "the existing row has expired" predicate is one + -- comparison at every stage. AbandonAsync sets it to now(), which + -- makes the released row satisfy that same predicate — a release + -- needs no second code path, and learnstack_app never needs DELETE. + expires_at timestamptz NOT NULL, + CONSTRAINT pk_idempotency_keys PRIMARY KEY (tenant_id, key), + CONSTRAINT ck_idempotency_keys_state + CHECK (state IN ('in_flight', 'completed', 'unreplayable')), + -- ADR-0037's replay cap is 256 KiB headers included, so this is a + -- floor under it rather than the cap: the database bounds the body + -- cheaply and the store enforces the headers-inclusive total. + CONSTRAINT ck_idempotency_keys_body_size + CHECK (body IS NULL OR octet_length(body) <= 262144), + -- Matches [Idempotent]'s header bounds, so a key the API accepted + -- always fits. + CONSTRAINT ck_idempotency_keys_key_length + CHECK (length(key) BETWEEN 8 AND 128) + ); + + -- Serves both the retention sweep and the per-tenant admission count, + -- tenant first per the composite-index rule. + CREATE INDEX ix_idempotency_keys_tenant_id_expires_at + ON idempotency_keys (tenant_id, expires_at); + """); + + // Both are tenant-owned, tenant-wide: no organization term, and + // therefore no restrictive guards — there is no organization to guard. + migrationBuilder.Sql(""" + ALTER TABLE outbox_messages ENABLE ROW LEVEL SECURITY; + ALTER TABLE outbox_messages FORCE ROW LEVEL SECURITY; + ALTER TABLE idempotency_keys ENABLE ROW LEVEL SECURITY; + ALTER TABLE idempotency_keys FORCE ROW LEVEL SECURITY; + + CREATE POLICY outbox_messages_isolation ON outbox_messages + USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid) + WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid); + + CREATE POLICY idempotency_keys_isolation ON idempotency_keys + USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid) + WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid); + """); + + // Application code only ever ENQUEUES, so learnstack_app gets no UPDATE + // and no DELETE on the outbox: status transitions belong to the + // dispatcher and purging to the audited platform scope. The dispatcher's + // BYPASSRLS is what lets it read every tenant's pending rows — and + // BYPASSRLS bypasses policies, not GRANTs, so the column list below is + // what actually bounds it. SELECT ... FOR UPDATE SKIP LOCKED works with a + // column-level UPDATE grant, so no table-wide UPDATE is needed. When + // locked_by and locked_until land in Phase 02b, that migration extends + // this grant; a column added without extending it fails at runtime with + // `permission denied for table`. + // + // idempotency_keys gives learnstack_app no DELETE either: a release is an + // UPDATE that backdates expires_at. + migrationBuilder.Sql(""" + GRANT SELECT, INSERT ON outbox_messages TO learnstack_app; + GRANT SELECT, DELETE ON outbox_messages TO learnstack_platform; + GRANT SELECT ON outbox_messages TO learnstack_outbox_admin; + GRANT UPDATE (processed_at, attempts, last_error, available_after) + ON outbox_messages TO learnstack_outbox_admin; + + GRANT SELECT, INSERT, UPDATE ON idempotency_keys TO learnstack_app; + GRANT SELECT, DELETE ON idempotency_keys TO learnstack_platform; + """); + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + DROP TABLE IF EXISTS idempotency_keys; + DROP TABLE IF EXISTS outbox_messages; + """); + + } + } +} diff --git a/backend/src/LearnStack.Infrastructure/Persistence/Migrations/PlatformDbContextModelSnapshot.cs b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/PlatformDbContextModelSnapshot.cs new file mode 100644 index 00000000..025218fd --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/PlatformDbContextModelSnapshot.cs @@ -0,0 +1,26 @@ +// +using LearnStack.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LearnStack.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(PlatformDbContext))] + partial class PlatformDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContext.cs b/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContext.cs new file mode 100644 index 00000000..b6c72584 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContext.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Infrastructure.Persistence; + +/// +/// The platform's own tables — the ones no module owns. +/// +/// +/// +/// outbox_messages and idempotency_keys are written through +/// SharedKernel ports (IOutbox, IIdempotencyStore) by any module's +/// handler and read by infrastructure that belongs to none of them. Putting them +/// in a module's context would make every other module's use of the outbox a +/// dependency on that module — the shape +/// 15-event-and-outbox.md +/// rules out when it says LearnStack uses a single shared table, not one per +/// module. +/// +/// +/// It is the second DbContext, and that is useful rather than +/// incidental. +/// ADR-0040 +/// says its central property — several contexts enlisted on one connection, so +/// SET LOCAL protects every statement — becomes testable only when a +/// second context exists, and expected that in Phase 03. It exists here, so the +/// property is testable a phase earlier. +/// +/// +/// Both tables are tenant-owned, tenant-wide despite living outside a +/// module: every row carries a tenant_id, and the ordinary policy applies. +/// The dispatcher reads across tenants through learnstack_outbox_admin's +/// BYPASSRLS, bounded by a column-scoped grant rather than by the policy. +/// +/// +public sealed class PlatformDbContext(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + ArgumentNullException.ThrowIfNull(modelBuilder); + + // No entity types. Both tables are written through their ports with + // parameterised SQL, not through a change tracker: an outbox row is + // enqueued and never updated by application code, and an idempotency + // claim is a single INSERT ... ON CONFLICT that decides five outcomes in + // one round trip (ADR-0037 Amendment 2). Mapping them as entities would + // add a model nothing queries and invite exactly the row-by-row use both + // designs avoid. + // + // The context exists to own the MIGRATION for these tables — that is what + // needs a model root — and to be the second context ADR-0040's property + // requires. + base.OnModelCreating(modelBuilder); + } +} diff --git a/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContextFactory.cs b/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContextFactory.cs new file mode 100644 index 00000000..21f451b8 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContextFactory.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace LearnStack.Infrastructure.Persistence; + +/// +/// Builds a for dotnet ef at design time. +/// +/// +/// Same contract, and same reason, as Tenancy's: it reads +/// ConnectionStrings__Migration and nothing else, with no fallback to +/// ConnectionStrings__Default. A fallback is how the runtime role becomes +/// the table owner by accident, which is the arrangement +/// FORCE ROW LEVEL SECURITY exists to defeat. +/// +public sealed class PlatformDbContextFactory : IDesignTimeDbContextFactory +{ + private const string ConnectionStringVariable = "ConnectionStrings__Migration"; + + public PlatformDbContext CreateDbContext(string[] args) + { + var connectionString = + ReadConnectionArgument(args) + ?? Environment.GetEnvironmentVariable(ConnectionStringVariable); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + $"{ConnectionStringVariable} is not set and no --connection was passed. " + + "Migrations run as learnstack_migration, which owns every table; " + + "`make migrate` is its sanctioned carrier."); + } + + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString, npgsql => + // Its own history table, so the two chains advance independently: + // a module's migration must not be blocked by, or block, the + // platform's. + npgsql.MigrationsHistoryTable("__ef_migrations_history_platform")) + .Options; + + return new PlatformDbContext(options); + } + + private static string? ReadConnectionArgument(string[] args) + { + if (args is null) + { + return null; + } + + for (var index = 0; index < args.Length - 1; index++) + { + if (string.Equals(args[index], "--connection", StringComparison.Ordinal)) + { + return args[index + 1]; + } + } + + return null; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PlatformSchemaTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PlatformSchemaTests.cs new file mode 100644 index 00000000..ab37efe0 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/PlatformSchemaTests.cs @@ -0,0 +1,245 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// outbox_messages and idempotency_keys — the two tables no module +/// owns. +/// +/// +/// They ship in their own migration chain, on their own history table, so a +/// module's migration cannot block the platform's or be blocked by it. Both are +/// tenant-owned and tenant-wide despite living outside a module: every row +/// carries a tenant_id and the ordinary policy applies. +/// +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class PlatformSchemaTests : IClassFixture +{ + private static readonly Guid TenantA = Guid.Parse("11111111-1111-7111-8111-111111111111"); + private static readonly Guid TenantB = Guid.Parse("22222222-2222-7222-8222-222222222222"); + + private readonly PlatformSchemaFixture _schema; + + public PlatformSchemaTests(PlatformSchemaFixture schema) => _schema = schema; + + [Theory] + [InlineData("outbox_messages")] + [InlineData("idempotency_keys")] + public async Task RowSecurityIsEnabledAndForced(string table) + { + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); + await using var command = new NpgsqlCommand( + "SELECT relrowsecurity AND relforcerowsecurity FROM pg_class WHERE relname = @table", + (NpgsqlConnection)connection); + command.Parameters.AddWithValue("table", table); + + (await command.ExecuteScalarAsync()).Should().Be(true); + } + + [Fact] + public async Task TheOutboxIdDefaultsToAVersion7Uuid() + { + // The corrected function name. `gen_uuid_v7()` — which six documents named + // before Packet 6 — does not exist in PostgreSQL 18; the built-in is + // `uuidv7()`, and a migration carrying the wrong one fails on every insert. + // Asserting the VERSION rather than that the insert succeeded, because + // gen_random_uuid() would also have succeeded and produced a v4 with none + // of the index locality ADR-0023 adopted v7 for. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SetTenantAsync(connection, transaction, TenantA); + + await using var insert = new NpgsqlCommand( + """ + INSERT INTO outbox_messages (tenant_id, correlation_id, type, topic, partition_key, payload) + VALUES (@tenant, '00-trace-span-01', 'T', 'learnstack.tenancy.tenant', 'k', '{}') + RETURNING uuid_extract_version(id) + """, (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + insert.Parameters.AddWithValue("tenant", TenantA); + + (await insert.ExecuteScalarAsync()).Should().Be((short)7); + } + + [Fact] + public async Task AnotherTenantCannotSeeOutboxRows() + { + await using var seed = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using (var seedTx = await seed.BeginTransactionAsync()) + { + await SetTenantAsync(seed, seedTx, TenantA); + await using var insert = new NpgsqlCommand( + """ + INSERT INTO outbox_messages (tenant_id, correlation_id, type, topic, partition_key, payload) + VALUES (@tenant, '00-trace-span-02', 'T', 'learnstack.tenancy.tenant', 'k', '{}') + """, (NpgsqlConnection)seed, (NpgsqlTransaction)seedTx); + insert.Parameters.AddWithValue("tenant", TenantA); + await insert.ExecuteNonQueryAsync(); + await seedTx.CommitAsync(); + } + + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SetTenantAsync(connection, transaction, TenantB); + + await using var read = new NpgsqlCommand( + "SELECT count(*) FROM outbox_messages", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + + (await read.ExecuteScalarAsync()).Should().Be(0L); + } + + [Fact] + public async Task TheApplicationRoleCanOnlyEnqueue() + { + // No UPDATE and no DELETE on the outbox: status transitions belong to the + // dispatcher and purging to the audited platform scope. A handler that + // could mark a row processed could make an event vanish. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var command = new NpgsqlCommand( + "DELETE FROM outbox_messages", (NpgsqlConnection)connection); + + var act = async () => await command.ExecuteNonQueryAsync(); + + (await act.Should().ThrowAsync()) + .Which.SqlState.Should().Be(PostgresErrorCodes.InsufficientPrivilege); + } + + [Fact] + public async Task TheDispatcherHoldsExactlyTheFourColumnUpdateGrant() + { + // BYPASSRLS bypasses policies, not GRANTs, so this column list is the + // whole of the bound on learnstack_outbox_admin. SELECT ... FOR UPDATE + // SKIP LOCKED works with a column-level grant, so no table-wide UPDATE is + // needed — and when locked_by / locked_until land in Phase 02b, that + // migration extends this grant or the dispatcher fails at runtime with + // `permission denied for table`. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); + await using var command = new NpgsqlCommand( + """ + SELECT string_agg(column_name, ',' ORDER BY column_name) + FROM information_schema.column_privileges + WHERE table_name = 'outbox_messages' + AND grantee = 'learnstack_outbox_admin' + AND privilege_type = 'UPDATE' + """, (NpgsqlConnection)connection); + + (await command.ExecuteScalarAsync()).Should() + .Be("attempts,available_after,last_error,processed_at"); + } + + [Fact] + public async Task TheIdempotencyBodyCapIsEnforcedByTheDatabase() + { + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SetTenantAsync(connection, transaction, TenantA); + + await using var command = new NpgsqlCommand( + """ + INSERT INTO idempotency_keys + (tenant_id, key, fingerprint, claim_token, state, expires_at, body) + VALUES (@tenant, 'oversized', 'fp', gen_random_uuid(), 'completed', now(), + repeat('x', 262145)::bytea) + """, (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + command.Parameters.AddWithValue("tenant", TenantA); + + var act = async () => await command.ExecuteNonQueryAsync(); + + (await act.Should().ThrowAsync()) + .Which.ConstraintName.Should().Be("ck_idempotency_keys_body_size"); + } + + [Fact] + public async Task TheTwoMigrationChainsAdvanceIndependently() + { + // Separate history tables, so a module's migration cannot block the + // platform's or be blocked by it — and, from step 6, so ADR-0040's + // multi-context property has two contexts to be true of. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); + await using var command = new NpgsqlCommand( + """ + SELECT string_agg(tablename, ',' ORDER BY tablename) + FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE '\_\_ef%' + """, (NpgsqlConnection)connection); + + (await command.ExecuteScalarAsync()).Should() + .Be("__ef_migrations_history_platform,__ef_migrations_history_tenancy"); + } + + private static async Task SetTenantAsync( + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + Guid tenantId) + { + await using var command = new NpgsqlCommand( + "SELECT set_config('app.tenant_id', @value, true)", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + command.Parameters.AddWithValue("value", tenantId.ToString()); + await command.ExecuteNonQueryAsync(); + } +} + +/// +/// Applies both migration chains and seeds the two tenants the isolation cases +/// compare — the platform tables carry a tenant_id, so a tenant must exist +/// for a row to reference even though no foreign key enforces it. +/// +public sealed class PlatformSchemaFixture : IAsyncLifetime +{ + public PostgresFixture Postgres { get; } = new(); + + public async Task InitializeAsync() + { + await Postgres.InitializeAsync(); + + var tenancy = new DbContextOptionsBuilder() + .UseNpgsql(Postgres.MigrationConnectionString, npgsql => + npgsql.MigrationsHistoryTable("__ef_migrations_history_tenancy")) + .Options; + + await using (var context = new Modules.Tenancy.Infrastructure.Persistence.TenancyDbContext(tenancy)) + { + await context.Database.MigrateAsync(); + } + + var platform = new DbContextOptionsBuilder() + .UseNpgsql(Postgres.MigrationConnectionString, npgsql => + npgsql.MigrationsHistoryTable("__ef_migrations_history_platform")) + .Options; + + await using (var context = new PlatformDbContext(platform)) + { + await context.Database.MigrateAsync(); + } + + await SeedTenantsAsync(); + } + + public async Task DisposeAsync() => await Postgres.DisposeAsync(); + + private async Task SeedTenantsAsync() + { + await using var connection = await PostgresFixture.OpenAsync(Postgres.MigrationConnectionString); + await using var command = new NpgsqlCommand( + """ + BEGIN; + SET LOCAL app.tenant_id = '11111111-1111-7111-8111-111111111111'; + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('11111111-1111-7111-8111-111111111111','alpha','Alpha','Trial', now(), + '00000000-0000-7000-8000-000000000001', 0); + COMMIT; + BEGIN; + SET LOCAL app.tenant_id = '22222222-2222-7222-8222-222222222222'; + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('22222222-2222-7222-8222-222222222222','beta','Beta','Trial', now(), + '00000000-0000-7000-8000-000000000001', 0); + COMMIT; + """, (NpgsqlConnection)connection); + + await command.ExecuteNonQueryAsync(); + } +} From d0db17b021979b683f46f7fa9fea265b5f2bc675 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 12:43:10 +0300 Subject: [PATCH 15/35] fix(tenancy): close the Step 4 Opus round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forty-six confirmed findings across six lenses, one of them a blocker: the migration this packet exists to ship could not be applied by any documented path. `dotnet ef` resolves the design-time package from the *startup* project and `make migrate` names `LearnStack.Api`, which did not reference it — so the tool refused before opening a connection, invisibly, because the Testcontainers fixture calls `Database.MigrateAsync()` directly. `make migrate` also never exported the value it read from `.env` (EF applies `--connection` after the design-time factory returns, so the factory threw first) and walked only `src/Modules`, leaving the platform chain unmigrated. All three are fixed and measured end to end against a real container; the dead `--connection` argument parser and the comment claiming it wins are gone. The rest divides into schema, proof, and record. Schema. `row_version` carried `HasDefaultValue(0L)` alone, which sets `ValueGenerated = OnAdd` — benign today and rejected by the rule this packet registered; ADR-0039 Amendment 2 fixes the chain at three calls and Standards 05 follows. `ux_tenant_domains_host` was table-wide, so a soft-deleted claim held a hostname against every other tenant forever, across a boundary RLS otherwise hides; it is now partial on `deleted_at IS NULL`, and Standards 05 names it as the second — and last — sanctioned global unique on a tenant-owned table. "NULLS NOT DISTINCT, which EF cannot express" was false on the pinned packages, and the raw-SQL workaround left an index in the snapshot against a constraint in the database; `.AreNullsDistinct(false)` puts model, snapshot and schema back on one object. `Down()` reversed nothing — it aborted on `DROP FUNCTION`, and would have aborted again on `DROP TABLE organizations`. Closed-set columns are `text` with their CHECK rather than `varchar(20)`, the two transcribed `DEFAULT now()` clauses are back, and the one foreign key with no index on its columns has one. Proof. `TheOwnerIsDeniedOnThePlatformScopedTable` asserted `count(*) = 0` on a table the fixture never populated: it passed with every policy dropped and row security disabled. That was the general shape of the problem — five of the eight tables held no rows at all, and both structural sweeps ran off a hand-written eight-name list that fails open for the next table. The fixture now fills every table for both tenants, tenant A with a second organization, and the sweeps enumerate the catalogue. Four mutants confirm the suite is not decorative: widening `organizations_isolation` to `USING (true)`, adding a second permissive SELECT to `platform_host_to_tenant`, dropping the partial predicate, and removing the host-mapping seed each fail exactly the case that names them. Record. Two catalogue-governed rules were implemented under invented spellings; they now carry the canonical names and the catalogue rows say Implemented. `Aggregates_With_Optimistic_Concurrency_Map_RowVersion` and `Organization_Aggregate_Declared_In_Tenancy_Domain` were registered to this packet and unwritten — both are written, and the first kills the `HasDefaultValue` mutant. The glossary classed `tenants` as a table living above tenants and called RLS "(later)", which the shipped schema falsifies twice over. ADR: ADR-0039 (Amendment 2), ADR-0003, ADR-0036 Module: Tenancy Co-Authored-By: Claude Opus 5 (1M context) --- Makefile | 18 +- .../src/LearnStack.Api/LearnStack.Api.csproj | 10 + .../Persistence/PlatformDbContextFactory.cs | 34 +- .../Persistence/IOptimisticConcurrency.cs | 6 +- .../Organization.cs | 3 +- .../PlatformProjections.cs | 10 +- .../TenantDomain.cs | 21 +- .../Persistence/Configurations.cs | 94 ++- ...8092437_create_tenancy_schema.Designer.cs} | 37 +- ...> 20260828092437_create_tenancy_schema.cs} | 75 +- .../TenancyDbContextModelSnapshot.cs | 35 +- .../Persistence/SnakeCaseNaming.cs | 6 +- .../Persistence/TenancyDbContextFactory.cs | 44 +- .../CrossCuttingFoundationTests.cs | 72 +- .../PersistenceConventionTests.cs | 118 +++ .../TenancyConventionTests.cs | 44 ++ .../Database/MigrationRollbackTests.cs | 125 ++++ .../Database/TenancySchemaTests.cs | 706 ++++++++++++++---- docs/architecture/02-domain-model.md | 2 +- docs/architecture/12-localization.md | 6 +- docs/architecture/21-feature-flags.md | 12 +- .../0039-optimistic-concurrency-token.md | 52 +- docs/glossary.md | 8 +- docs/modules/tenancy/README.md | 12 +- docs/roadmap/phase-02a-kernel-tenancy.md | 20 +- docs/standards/05-database.md | 33 +- .../21-architecture-tests-catalogue.md | 62 +- 27 files changed, 1303 insertions(+), 362 deletions(-) rename backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/{20260828082209_create_tenancy_schema.Designer.cs => 20260828092437_create_tenancy_schema.Designer.cs} (95%) rename backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/{20260828082209_create_tenancy_schema.cs => 20260828092437_create_tenancy_schema.cs} (93%) create mode 100644 backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs diff --git a/Makefile b/Makefile index a2d33053..213e4480 100644 --- a/Makefile +++ b/Makefile @@ -118,7 +118,7 @@ build-frontend: ## `pnpm -r build` the frontend monorepo. (cd frontend && pnpm -r build) .PHONY: migrate -migrate: ## Apply every module's EF migrations as `learnstack_migration` (the ONLY sanctioned carrier of that credential). +migrate: ## Apply every EF migration chain (platform + each module) as `learnstack_migration` (the ONLY sanctioned carrier of that credential). @# Standards 05 § Database roles: ConnectionStrings:Migration must never appear @# in API or worker runtime configuration. The role OWNS every table it creates, @# and a runtime that is the owner is precisely the arrangement FORCE ROW LEVEL @@ -130,6 +130,13 @@ migrate: ## Apply every module's EF migrations as `learnstack_migration` (the ON @# learnstack_app role, which holds USAGE but not CREATE on schema public and @# fails with `permission denied for schema public`. The tempting fix for that @# error (granting it CREATE) is the ownership mistake above. + @# + @# `--connection` alone is NOT enough, which is why the value is also exported. + @# `dotnet ef` consumes `--connection` in its own parser and applies it to the + @# context AFTER the design-time factory has returned, so the factory never + @# sees it in `args` and throws first on a workstation whose value lives only + @# in `.env`. Measured. The export is what lets the factory construct; the + @# flag is what EF then applies. @# Four things this recipe does that a naive version does not, each because the @# naive version was measured doing the wrong thing: @# @@ -150,6 +157,10 @@ migrate: ## Apply every module's EF migrations as `learnstack_migration` (the ON @# a for-loop body that is part of a compound list: both iterations ran @# after `false` and the recipe still exited 0, so a migration target @# reported success after every migration failed. + @# 5. The loop covers `LearnStack.Infrastructure` as well as the modules. The + @# platform chain (outbox_messages, idempotency_keys) lives outside + @# `src/Modules`, and a glob that only walked the modules left the two + @# tables no module owns unmigrated on every documented path. @migration_cs="$${ConnectionStrings__Migration:-}"; \ if [ -z "$$migration_cs" ] && [ -f .env ]; then \ migration_cs=$$(sed -n "s/^ConnectionStrings__Migration=//p" .env \ @@ -174,10 +185,11 @@ migrate: ## Apply every module's EF migrations as `learnstack_migration` (the ON echo "If the value looks truncated at the first ';', quote it in .env."; \ exit 1; \ fi; \ + export ConnectionStrings__Migration="$$migration_cs"; \ dotnet tool restore >/dev/null; \ found=0; \ failed=0; \ - for proj in backend/src/Modules/*/LearnStack.Modules.*.Infrastructure; do \ + for proj in backend/src/LearnStack.Infrastructure backend/src/Modules/*/LearnStack.Modules.*.Infrastructure; do \ test -d "$$proj/Persistence/Migrations" || continue; \ found=1; \ echo "==> $$(basename $$proj)"; \ @@ -187,7 +199,7 @@ migrate: ## Apply every module's EF migrations as `learnstack_migration` (the ON --connection "$$migration_cs" || failed=1; \ done; \ if [ "$$found" = "0" ]; then \ - echo "No module carries Persistence/Migrations yet — the first lands with the Tenancy schema in Phase 02a Packet 6."; \ + echo "No project carries Persistence/Migrations yet — the first lands with the Tenancy schema in Phase 02a Packet 6."; \ fi; \ exit "$$failed" diff --git a/backend/src/LearnStack.Api/LearnStack.Api.csproj b/backend/src/LearnStack.Api/LearnStack.Api.csproj index 64e21d7b..a830dc6e 100644 --- a/backend/src/LearnStack.Api/LearnStack.Api.csproj +++ b/backend/src/LearnStack.Api/LearnStack.Api.csproj @@ -49,6 +49,16 @@ + + +
diff --git a/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContextFactory.cs b/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContextFactory.cs index 21f451b8..9a7a0d42 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContextFactory.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContextFactory.cs @@ -8,10 +8,12 @@ namespace LearnStack.Infrastructure.Persistence; ///
/// /// Same contract, and same reason, as Tenancy's: it reads -/// ConnectionStrings__Migration and nothing else, with no fallback to -/// ConnectionStrings__Default. A fallback is how the runtime role becomes -/// the table owner by accident, which is the arrangement -/// FORCE ROW LEVEL SECURITY exists to defeat. +/// ConnectionStrings__Migration from the environment and nothing else, with +/// no fallback to ConnectionStrings__Default. A fallback is how the runtime +/// role becomes the table owner by accident, which is the arrangement +/// FORCE ROW LEVEL SECURITY exists to defeat. dotnet ef --connection +/// never reaches args — EF applies it after the factory returns — so the +/// exported variable is the only thing that lets this construct. /// public sealed class PlatformDbContextFactory : IDesignTimeDbContextFactory { @@ -19,14 +21,12 @@ public sealed class PlatformDbContextFactory : IDesignTimeDbContextFactoryAuditableEntity, by the same primitive that stamps the audit /// columns, so an audited mutation is a versioned mutation /// (ADR-0039). - /// The property is configured with IsConcurrencyToken() and nothing - /// else: adding ValueGeneratedOnAddOrUpdate() — or the equivalent + /// The property is configured with + /// HasDefaultValue(0L).IsConcurrencyToken().ValueGeneratedNever() and + /// nothing else (ADR-0039 Amendment 2). Adding + /// ValueGeneratedOnAddOrUpdate() — or the equivalent /// IsRowVersion() — tells EF the database generates the value, and /// EF then omits the column from the UPDATE entirely. Measured: the /// persisted value stays 0 for the life of the row and every lost diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs index bdac7b2f..a49d33cb 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs @@ -14,7 +14,8 @@ namespace LearnStack.Modules.Tenancy.Domain; /// LearnStack.Modules.Identity.Domain.Entities; Amendment 2 (2026-08-10) /// moved it to Tenancy, and Identity holds by value /// and reads organization data through an application contract. The architecture -/// rule Organization_Aggregate_Declared_In_Tenancy_Domain is what keeps a +/// rule Organization_Aggregate_Declared_In_Tenancy_Domain — +/// TenancyConventionTests, introduced with this aggregate — is what keeps a /// second declaration from appearing. /// /// diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs index 686a6dac..50396c61 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs @@ -24,9 +24,13 @@ namespace LearnStack.Modules.Tenancy.Domain; /// § Table classes). /// /// -/// Written only through IEntitlementProvider.RefreshAsync. Modules never -/// write it directly, which Modules_Do_Not_Read_Entitlement_Cache_Directly -/// enforces. +/// Written only through IEntitlementProvider.RefreshAsync, and read only +/// through IEntitlementProvider. The read half is +/// Modules_Do_Not_Read_Entitlement_Cache_Directly, catalogued and owed by +/// Packet 10 +/// — it is not in force yet, and the write half has no rule at all until +/// Phase 02c +/// ships RefreshAsync for it to guard. /// /// public sealed class PlatformEntitlement diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs index 6e68e462..3587962f 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs @@ -1,5 +1,6 @@ using LearnStack.SharedKernel.Domain; using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; using LearnStack.SharedKernel.Time; namespace LearnStack.Modules.Tenancy.Domain; @@ -124,14 +125,18 @@ private static TenantDomain CreateCore( throw new ArgumentException("A domain belongs to a tenant.", nameof(tenantId)); } - // The database carries the same rule as a CHECK; this is the loud half, so - // a caller that skipped EffectiveHost.Normalize learns it here rather than - // as a constraint violation three layers down. - // `host.Any(char.IsUpper)`, not `host != host.ToLowerInvariant()`: the - // question is whether the string IS lowercase, not whether two strings - // match case-insensitively — and the analyzer's suggested fix for the - // latter (OrdinalIgnoreCase) would make this check always pass. - if (host.Any(char.IsUpper)) + // The database carries the same rule as ck_tenant_domains_host_normalized; + // this is the loud half, so a caller that skipped EffectiveHost.Normalize + // learns it here rather than as a constraint violation three layers down. + // + // Asked by running the normalizer, not by testing one of its four + // properties. An earlier version checked case alone while its message + // named all four — lowercase, punycoded, no port, no trailing dot — so a + // host carrying a port or an IDN label passed the aggregate and failed at + // the CHECK. Normalize returns null for a host it cannot normalize at all, + // and the input unchanged for one that is already normalized, so + // inequality is the whole question. + if (!string.Equals(EffectiveHost.Normalize(host), host, StringComparison.Ordinal)) { throw new ArgumentException( "Host must be normalized before it reaches the aggregate: lowercase, punycoded, no port, no trailing dot. Use EffectiveHost.Normalize.", diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs index 4d421a5a..5cec1f5c 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs @@ -16,14 +16,21 @@ internal static class TenancyMapping /// /// /// - /// IsConcurrencyToken() and nothing else. Adding - /// ValueGeneratedOnAddOrUpdate() — or the equivalent - /// IsRowVersion() — tells EF the database generates the value, and EF - /// then omits the column from the UPDATE entirely: measured, the - /// persisted value stays 0 for the life of the row, every - /// If-Match compares equal, and a lost update succeeds while reporting - /// success (ADR-0039 - /// Amendment 1). + /// The concurrency token takes exactly three calls, and + /// ADR-0039 + /// Amendment 2 fixes them: HasDefaultValue(0L) for the DDL + /// template's DEFAULT 0, IsConcurrencyToken() for the token + /// itself, and ValueGeneratedNever() because the first call otherwise + /// leaves ValueGenerated at OnAdd — which is what + /// Aggregates_With_Optimistic_Concurrency_Map_RowVersion rejects. + /// + /// + /// ValueGeneratedOnAddOrUpdate() — and the equivalent + /// IsRowVersion() — are the two calls that may never appear. They tell + /// EF the database generates the value, and EF then omits the column from the + /// UPDATE entirely: measured, the persisted value stays 0 for the + /// life of the row, every If-Match compares equal, and a lost update + /// succeeds while reporting success (ADR-0039 Amendment 1). /// /// /// updated_at / updated_by are nullable because @@ -53,7 +60,8 @@ public static void MapAuditColumns(this EntityTypeBuilder builder.Property(x => x.Version) .HasColumnName("row_version") .HasDefaultValue(0L) - .IsConcurrencyToken(); + .IsConcurrencyToken() + .ValueGeneratedNever(); } /// @@ -64,13 +72,18 @@ public static void MapAuditColumns(this EntityTypeBuilder /// removed or reordered, and not an int, which makes a dump unreadable /// and a mistyped value indistinguishable from a valid one. The migration adds /// the matching CHECK, which is what actually bounds the column — - /// this only decides how it is written. + /// this only decides how it is written. The store type is text, not + /// varchar(n): Database Standards § Column types fixes the canonical + /// form for a closed set as "text NOT NULL with a + /// CHECK (col IN (…))", and a length cap beside an enumerating CHECK + /// is a second, weaker bound that can only disagree with the first. /// public static PropertyBuilder HasEnumAsText(this PropertyBuilder builder) where TEnum : struct, Enum => builder.HasConversion( - value => value.ToString(), - text => Enum.Parse(text, ignoreCase: false)); + value => value.ToString(), + text => Enum.Parse(text, ignoreCase: false)) + .HasColumnType("text"); } @@ -87,7 +100,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Slug).HasMaxLength(63).IsRequired(); builder.Property(x => x.DisplayName).HasMaxLength(200).IsRequired(); - builder.Property(x => x.Status).HasEnumAsText().HasMaxLength(20).IsRequired(); + builder.Property(x => x.Status).HasEnumAsText().IsRequired(); builder.Property(x => x.DefaultOrganizationId) .HasConversion(); @@ -120,7 +133,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Slug).HasMaxLength(63).IsRequired(); builder.Property(x => x.DisplayName).HasMaxLength(200).IsRequired(); builder.Property(x => x.CustomSubdomain).HasMaxLength(253); - builder.Property(x => x.Status).HasEnumAsText().HasMaxLength(20).IsRequired(); + builder.Property(x => x.Status).HasEnumAsText().IsRequired(); builder.Property(x => x.ReportingParentId) .HasConversion(); @@ -138,6 +151,13 @@ public void Configure(EntityTypeBuilder builder) .IsUnique() .HasDatabaseName("ux_organizations_tenant_id_id"); + // fk_organizations_reporting_parent's own columns. Standards 05 § Indexes + // says index every foreign key, and the two indexes above only lead with + // tenant_id — neither can serve the ON DELETE RESTRICT scan that looks for + // children of the organization being deleted. + builder.HasIndex(x => new { x.TenantId, x.ReportingParentId }) + .HasDatabaseName("ix_organizations_tenant_id_reporting_parent_id"); + builder.MapAuditColumns(); } } @@ -158,15 +178,27 @@ public void Configure(EntityTypeBuilder builder) .IsRequired(); builder.Property(x => x.Host).HasMaxLength(253).IsRequired(); - builder.Property(x => x.Kind).HasEnumAsText().HasMaxLength(20).IsRequired(); - builder.Property(x => x.Status).HasEnumAsText().HasMaxLength(20).IsRequired(); + builder.Property(x => x.Kind).HasEnumAsText().IsRequired(); + builder.Property(x => x.Status).HasEnumAsText().IsRequired(); builder.Property(x => x.VerifiedAt); builder.Property(x => x.VerificationAttempts).HasDefaultValue(0).IsRequired(); builder.Property(x => x.LastVerificationError).HasMaxLength(1000); - // Globally unique: a host resolving to two tenants is unresolvable - // regardless of who owns it. - builder.HasIndex(x => x.Host).IsUnique().HasDatabaseName("ux_tenant_domains_host"); + // Globally unique, and the second — with `tenants.slug` — of the two + // cases Database Standards § Table classes sanctions on a tenant-owned + // table: a host resolving to two tenants is unresolvable regardless of who + // owns it. It costs the same leak the slug costs, because PostgreSQL + // enforces unique indexes with row security bypassed, so a duplicate + // insert reveals that *some* tenant already claims the host. + // + // Partial on `deleted_at IS NULL`, which is not decoration: without the + // predicate a soft-deleted claim keeps the name forever, and ADR-0036 + // § Custom domains contemplates a released-then-re-registered domain — + // a lifecycle a table-wide unique makes unimplementable. + builder.HasIndex(x => x.Host) + .IsUnique() + .HasFilter("deleted_at IS NULL") + .HasDatabaseName("ux_tenant_domains_host"); builder.HasIndex(x => x.TenantId).HasDatabaseName("ix_tenant_domains_tenant_id"); builder.MapAuditColumns(); @@ -216,11 +248,15 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Key).HasMaxLength(200).IsRequired(); builder.Property(x => x.Value).HasColumnType("jsonb").IsRequired(); - // The uniqueness is NULLS NOT DISTINCT, which EF cannot express — the - // migration replaces this index with the correct form. Declared here so - // the model still knows the shape; see the migration for why. + // NULLS NOT DISTINCT, expressed in the model rather than patched into the + // migration afterwards: organization_id is null on every tenant-wide row, + // and a standard UNIQUE treats nulls as distinct — so without this a tenant + // could hold unlimited duplicate tenant-wide rows for one key, which is + // precisely the set a single-organization tenant creates exclusively, and + // resolution would pick one arbitrarily. builder.HasIndex(x => new { x.TenantId, x.OrganizationId, x.Key }) .IsUnique() + .AreNullsDistinct(false) .HasDatabaseName("ux_tenant_settings_tenant_id_organization_id_key"); builder.HasIndex(x => new { x.TenantId, x.OrganizationId }) @@ -243,7 +279,10 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Key).HasMaxLength(200).IsRequired(); builder.Property(x => x.Value).HasColumnType("jsonb").IsRequired(); - builder.Property(x => x.UpdatedAt).IsRequired(); + + // DEFAULT now(), as 21-feature-flags.md declares it. A flag row written by + // raw SQL that omits the column would otherwise violate NOT NULL. + builder.Property(x => x.UpdatedAt).HasDefaultValueSql("now()").IsRequired(); builder.Property(x => x.UpdatedBy) .HasConversion() @@ -271,8 +310,13 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.ValidUntil).IsRequired(); builder.Property(x => x.GraceUntil); builder.Property(x => x.Generation).HasDefaultValue(1L).IsRequired(); - builder.Property(x => x.RefreshedAt).IsRequired(); - builder.Property(x => x.Source).HasMaxLength(50).IsRequired(); + + // DEFAULT now(), as 21-feature-flags.md declares it — the same reason as + // tenant_feature_flags.updated_at, and the same provisioning insert. + builder.Property(x => x.RefreshedAt).HasDefaultValueSql("now()").IsRequired(); + + // Closed set, so text + CHECK rather than a length cap. + builder.Property(x => x.Source).HasColumnType("text").IsRequired(); } } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828082209_create_tenancy_schema.Designer.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.Designer.cs similarity index 95% rename from backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828082209_create_tenancy_schema.Designer.cs rename to backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.Designer.cs index efd2cbb2..747d8e27 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828082209_create_tenancy_schema.Designer.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.Designer.cs @@ -12,7 +12,7 @@ namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence.Migrations { [DbContext(typeof(TenancyDbContext))] - [Migration("20260828082209_create_tenancy_schema")] + [Migration("20260828092437_create_tenancy_schema")] partial class create_tenancy_schema { /// @@ -70,8 +70,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Status") .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)") + .HasColumnType("text") .HasColumnName("status"); b.Property("TenantId") @@ -88,7 +87,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Version") .IsConcurrencyToken() - .ValueGeneratedOnAdd() .HasColumnType("bigint") .HasDefaultValue(0L) .HasColumnName("row_version"); @@ -100,6 +98,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsUnique() .HasDatabaseName("ux_organizations_tenant_id_id"); + b.HasIndex("TenantId", "ReportingParentId") + .HasDatabaseName("ix_organizations_tenant_id_reporting_parent_id"); + b.HasIndex("TenantId", "Slug") .IsUnique() .HasDatabaseName("ux_organizations_tenant_id_slug"); @@ -145,13 +146,14 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("plan_code"); b.Property("RefreshedAt") + .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") - .HasColumnName("refreshed_at"); + .HasColumnName("refreshed_at") + .HasDefaultValueSql("now()"); b.Property("Source") .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)") + .HasColumnType("text") .HasColumnName("source"); b.Property("ValidUntil") @@ -236,8 +238,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Status") .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)") + .HasColumnType("text") .HasColumnName("status"); b.Property("UpdatedAt") @@ -250,7 +251,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Version") .IsConcurrencyToken() - .ValueGeneratedOnAdd() .HasColumnType("bigint") .HasDefaultValue(0L) .HasColumnName("row_version"); @@ -295,8 +295,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Kind") .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)") + .HasColumnType("text") .HasColumnName("kind"); b.Property("LastVerificationError") @@ -306,8 +305,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Status") .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)") + .HasColumnType("text") .HasColumnName("status"); b.Property("TenantId") @@ -334,7 +332,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Version") .IsConcurrencyToken() - .ValueGeneratedOnAdd() .HasColumnType("bigint") .HasDefaultValue(0L) .HasColumnName("row_version"); @@ -344,7 +341,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("Host") .IsUnique() - .HasDatabaseName("ux_tenant_domains_host"); + .HasDatabaseName("ux_tenant_domains_host") + .HasFilter("deleted_at IS NULL"); b.HasIndex("TenantId") .HasDatabaseName("ix_tenant_domains_tenant_id"); @@ -364,8 +362,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("key"); b.Property("UpdatedAt") + .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); b.Property("UpdatedBy") .HasColumnType("uuid") @@ -466,7 +466,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Version") .IsConcurrencyToken() - .ValueGeneratedOnAdd() .HasColumnType("bigint") .HasDefaultValue(0L) .HasColumnName("row_version"); @@ -481,6 +480,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsUnique() .HasDatabaseName("ux_tenant_settings_tenant_id_organization_id_key"); + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("TenantId", "OrganizationId", "Key"), false); + b.ToTable("tenant_settings", (string)null); }); #pragma warning restore 612, 618 diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828082209_create_tenancy_schema.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.cs similarity index 93% rename from backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828082209_create_tenancy_schema.cs rename to backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.cs index 0da0f420..2109b04a 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828082209_create_tenancy_schema.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.cs @@ -20,7 +20,7 @@ protected override void Up(MigrationBuilder migrationBuilder) slug = table.Column(type: "character varying(63)", maxLength: 63, nullable: false), display_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), custom_subdomain = table.Column(type: "character varying(253)", maxLength: 253, nullable: true), - status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + status = table.Column(type: "text", nullable: false), reporting_parent_id = table.Column(type: "uuid", nullable: true), created_at = table.Column(type: "timestamp with time zone", nullable: false), created_by = table.Column(type: "uuid", nullable: false), @@ -47,8 +47,8 @@ protected override void Up(MigrationBuilder migrationBuilder) valid_until = table.Column(type: "timestamp with time zone", nullable: false), grace_until = table.Column(type: "timestamp with time zone", nullable: true), generation = table.Column(type: "bigint", nullable: false, defaultValue: 1L), - refreshed_at = table.Column(type: "timestamp with time zone", nullable: false), - source = table.Column(type: "character varying(50)", maxLength: 50, nullable: false) + refreshed_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + source = table.Column(type: "text", nullable: false) }, constraints: table => { @@ -77,8 +77,8 @@ protected override void Up(MigrationBuilder migrationBuilder) id = table.Column(type: "uuid", nullable: false), tenant_id = table.Column(type: "uuid", nullable: false), host = table.Column(type: "character varying(253)", maxLength: 253, nullable: false), - kind = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + kind = table.Column(type: "text", nullable: false), + status = table.Column(type: "text", nullable: false), verified_at = table.Column(type: "timestamp with time zone", nullable: true), verification_attempts = table.Column(type: "integer", nullable: false, defaultValue: 0), last_verification_error = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), @@ -102,7 +102,7 @@ protected override void Up(MigrationBuilder migrationBuilder) tenant_id = table.Column(type: "uuid", nullable: false), key = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), value = table.Column(type: "jsonb", nullable: false), - updated_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), updated_by = table.Column(type: "uuid", nullable: false) }, constraints: table => @@ -154,7 +154,7 @@ protected override void Up(MigrationBuilder migrationBuilder) id = table.Column(type: "uuid", nullable: false), slug = table.Column(type: "character varying(63)", maxLength: 63, nullable: false), display_name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + status = table.Column(type: "text", nullable: false), default_organization_id = table.Column(type: "uuid", nullable: true), created_at = table.Column(type: "timestamp with time zone", nullable: false), created_by = table.Column(type: "uuid", nullable: false), @@ -169,6 +169,11 @@ protected override void Up(MigrationBuilder migrationBuilder) table.PrimaryKey("pk_tenants", x => x.id); }); + migrationBuilder.CreateIndex( + name: "ix_organizations_tenant_id_reporting_parent_id", + table: "organizations", + columns: new[] { "tenant_id", "reporting_parent_id" }); + migrationBuilder.CreateIndex( name: "ux_organizations_tenant_id_id", table: "organizations", @@ -195,7 +200,8 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "ux_tenant_domains_host", table: "tenant_domains", column: "host", - unique: true); + unique: true, + filter: "deleted_at IS NULL"); migrationBuilder.CreateIndex( name: "ix_tenant_settings_tenant_id_organization_id", @@ -206,7 +212,8 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "ux_tenant_settings_tenant_id_organization_id_key", table: "tenant_settings", columns: new[] { "tenant_id", "organization_id", "key" }, - unique: true); + unique: true) + .Annotation("Npgsql:NullsDistinct", false); migrationBuilder.CreateIndex( name: "ux_tenants_slug", @@ -327,21 +334,6 @@ ADD CONSTRAINT ck_tenant_domains_host_normalized CHECK ( AND length(host) <= 253); """); - // ── tenant_settings uniqueness: NULLS NOT DISTINCT ─────────────── - // EF cannot express it, so the index it generated is dropped and - // replaced. Load-bearing: organization_id is null on every tenant-wide - // row, and a standard UNIQUE treats nulls as distinct — so without this - // a tenant could hold unlimited duplicate tenant-wide rows for one key, - // which is precisely the set a single-organization tenant creates - // exclusively, and resolution would pick one arbitrarily. - migrationBuilder.Sql(""" - DROP INDEX ux_tenant_settings_tenant_id_organization_id_key; - - ALTER TABLE tenant_settings - ADD CONSTRAINT ux_tenant_settings_tenant_id_organization_id_key - UNIQUE NULLS NOT DISTINCT (tenant_id, organization_id, key); - """); - // ── organization_id is immutable after insert ──────────────────── // IS DISTINCT FROM rather than <>, so a move to or from NULL — // tenant-wide to org-scoped, or back — is caught too; <> is NULL when @@ -563,33 +555,52 @@ FOR DELETE TO learnstack_app /// protected override void Down(MigrationBuilder migrationBuilder) { - // The tables go with DropTable below; what needs explicit removal is - // the one object that is not owned by a table. - migrationBuilder.Sql("DROP FUNCTION IF EXISTS fn_organization_id_immutable();"); + // Ordered, and the order is the whole of it. DropTable emits a bare + // DROP TABLE with no CASCADE, so a reversal that runs in the + // alphabetical order EF scaffolds aborts on its first statement and + // reverses nothing — measured, twice: DROP FUNCTION first fails + // because the trigger on tenant_settings depends on it, and + // DROP TABLE organizations fails because three foreign keys + // reference it. + // + // Reversal is expected here because this migration is + // non-destructive: it only creates (Database Standards § Migrations). + // MigrationRollbackTests runs this path against a real database + // rather than trusting the reasoning in this comment. - migrationBuilder.DropTable( - name: "organizations"); + // The cycle first: tenants -> organizations -> tenants. Nothing can be + // dropped while it stands. + migrationBuilder.Sql( + "ALTER TABLE tenants DROP CONSTRAINT fk_tenants_default_organization;"); + // Children of organizations. migrationBuilder.DropTable( - name: "platform_entitlement_cache"); + name: "tenant_settings"); migrationBuilder.DropTable( name: "platform_host_to_tenant"); + // Children of tenants. migrationBuilder.DropTable( name: "tenant_domains"); + migrationBuilder.DropTable( + name: "tenant_locales"); + migrationBuilder.DropTable( name: "tenant_feature_flags"); migrationBuilder.DropTable( - name: "tenant_locales"); + name: "platform_entitlement_cache"); migrationBuilder.DropTable( - name: "tenant_settings"); + name: "organizations"); migrationBuilder.DropTable( name: "tenants"); + + // Last: the trigger that depended on it went with tenant_settings. + migrationBuilder.Sql("DROP FUNCTION fn_organization_id_immutable();"); } } } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs index 85be2ab5..d1e42743 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs @@ -67,8 +67,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Status") .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)") + .HasColumnType("text") .HasColumnName("status"); b.Property("TenantId") @@ -85,7 +84,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Version") .IsConcurrencyToken() - .ValueGeneratedOnAdd() .HasColumnType("bigint") .HasDefaultValue(0L) .HasColumnName("row_version"); @@ -97,6 +95,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnique() .HasDatabaseName("ux_organizations_tenant_id_id"); + b.HasIndex("TenantId", "ReportingParentId") + .HasDatabaseName("ix_organizations_tenant_id_reporting_parent_id"); + b.HasIndex("TenantId", "Slug") .IsUnique() .HasDatabaseName("ux_organizations_tenant_id_slug"); @@ -142,13 +143,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("plan_code"); b.Property("RefreshedAt") + .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") - .HasColumnName("refreshed_at"); + .HasColumnName("refreshed_at") + .HasDefaultValueSql("now()"); b.Property("Source") .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)") + .HasColumnType("text") .HasColumnName("source"); b.Property("ValidUntil") @@ -233,8 +235,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Status") .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)") + .HasColumnType("text") .HasColumnName("status"); b.Property("UpdatedAt") @@ -247,7 +248,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Version") .IsConcurrencyToken() - .ValueGeneratedOnAdd() .HasColumnType("bigint") .HasDefaultValue(0L) .HasColumnName("row_version"); @@ -292,8 +292,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Kind") .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)") + .HasColumnType("text") .HasColumnName("kind"); b.Property("LastVerificationError") @@ -303,8 +302,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Status") .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)") + .HasColumnType("text") .HasColumnName("status"); b.Property("TenantId") @@ -331,7 +329,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Version") .IsConcurrencyToken() - .ValueGeneratedOnAdd() .HasColumnType("bigint") .HasDefaultValue(0L) .HasColumnName("row_version"); @@ -341,7 +338,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Host") .IsUnique() - .HasDatabaseName("ux_tenant_domains_host"); + .HasDatabaseName("ux_tenant_domains_host") + .HasFilter("deleted_at IS NULL"); b.HasIndex("TenantId") .HasDatabaseName("ix_tenant_domains_tenant_id"); @@ -361,8 +359,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("key"); b.Property("UpdatedAt") + .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") - .HasColumnName("updated_at"); + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); b.Property("UpdatedBy") .HasColumnType("uuid") @@ -463,7 +463,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Version") .IsConcurrencyToken() - .ValueGeneratedOnAdd() .HasColumnType("bigint") .HasDefaultValue(0L) .HasColumnName("row_version"); @@ -478,6 +477,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnique() .HasDatabaseName("ux_tenant_settings_tenant_id_organization_id_key"); + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("TenantId", "OrganizationId", "Key"), false); + b.ToTable("tenant_settings", (string)null); }); #pragma warning restore 612, 618 diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/SnakeCaseNaming.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/SnakeCaseNaming.cs index 901d35eb..a29b75ed 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/SnakeCaseNaming.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/SnakeCaseNaming.cs @@ -23,8 +23,10 @@ namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; /// Database Standards /// is written against snake_case identifiers. Naming sixty columns by hand means a /// forgotten one is silently PascalCase — a column the policy does not -/// mention and the grant does not cover. Every_Mapped_Identifier_Is_Snake_Case -/// is what makes the omission impossible rather than unlikely. +/// mention and the grant does not cover. +/// TenancySchemaTests.EveryMappedIdentifierIsSnakeCase sweeps the applied +/// catalogue for the survivors; the convention is what makes the omission +/// impossible rather than unlikely. /// /// internal static class SnakeCaseNaming diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs index 720c2c92..32991275 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs @@ -24,10 +24,18 @@ namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; /// else. No appsettings, no user secrets, no fallback to /// ConnectionStrings__Default: a fallback is how the wrong role gets used /// by accident, and the whole point of the four-role split is that using the -/// wrong one is loud. make migrate is the sanctioned carrier and supplies +/// wrong one is loud. make migrate is the sanctioned carrier and exports /// the value. /// /// +/// dotnet ef --connection does not reach this method. The tool +/// consumes that option in its own parser and applies it to the context after the +/// factory has returned, so args never carries it and a factory that waited +/// for it would throw first — measured, on a workstation whose value lives only in +/// .env. The environment variable is what lets the context be constructed; +/// the flag is what EF then applies to it. +/// +/// /// Design-time only. Nothing at runtime constructs a context this way — ADR-0040 /// has every module context built on the connection IUnitOfWork owns. /// @@ -38,22 +46,18 @@ public sealed class TenancyDbContextFactory : IDesignTimeDbContextFactory() @@ -63,22 +67,4 @@ public TenancyDbContext CreateDbContext(string[] args) return new TenancyDbContext(options); } - - private static string? ReadConnectionArgument(string[] args) - { - if (args is null) - { - return null; - } - - for (var index = 0; index < args.Length - 1; index++) - { - if (string.Equals(args[index], "--connection", StringComparison.Ordinal)) - { - return args[index + 1]; - } - } - - return null; - } } diff --git a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs index 779c206b..76831b02 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs @@ -421,8 +421,19 @@ public void Modules_Do_Not_Inject_IEventBus_Directly() "IServiceProvider is a service-locator escape hatch"); UsesForbiddenEventBusAccess(typeof(DeliberateMethodPublisher)).Should().BeTrue( "method injection is still direct event-bus access"); + UsesForbiddenEventBusAccess(typeof(DeliberateInheritingPublisher)).Should().BeTrue( + "inheriting the surface is the same violation with one extra hop"); UsesForbiddenEventBusAccess(typeof(CrossCuttingFoundationTests)).Should().BeFalse(); + // The other direction, pinned on the one real type that exercises it: + // TenancyDbContext inherits IInfrastructure from + // DbContext. A rule that read inherited members without asking where they + // are declared flags every module context in the repository. + UsesForbiddenEventBusAccess( + typeof(LearnStack.Modules.Tenancy.Infrastructure.Persistence.TenancyDbContext)) + .Should().BeFalse( + "what EF Core declares on DbContext is not what a module author wrote"); + foreach (var name in ModuleAssemblyShapes) { var assembly = TryLoadAssembly(name); @@ -454,9 +465,13 @@ public void Modules_Do_Not_Inject_IEventBus_Directly() /// nested inside a hand-written type. /// /// - /// Walks the declaring chain because Vogen's EfCoreValueConverter and - /// <Name>TypeConverter are nested inside the value object, and the - /// attribute sits on the nested type rather than on the outer one. + /// Walks the declaring chain because Vogen stamps [GeneratedCode] on + /// the outer value object and on none of the converters nested inside + /// it — so a nested EfCoreValueConverter or + /// <Name>TypeConverter is only reachable as generated through its + /// declaring type. The side effect is that the outer [ValueObject] + /// partial, which a developer co-writes, is excluded too; that is accepted + /// because a strongly-typed id has no constructor a bus could arrive through. /// private static bool IsGenerated(Type type) { @@ -477,29 +492,40 @@ private static bool UsesForbiddenEventBusAccess(Type type) var bus = typeof(LearnStack.SharedKernel.Messaging.IEventBus); var serviceProvider = typeof(IServiceProvider); var forbidden = new[] { bus, serviceProvider }; - // DeclaredOnly, and it is load-bearing rather than an optimisation. Without - // it the check reads INHERITED members too, and DbContext implements - // IInfrastructure — so every module's DbContext was - // flagged the moment the first one existed, for something EF declares and - // the module author never wrote. The question this rule asks is what THIS - // type does; what a base type exposes is the base type's business, and - // DbContext is not a module type. + + // Inherited members ARE read, and then filtered by where they are + // DECLARED. The obvious form — BindingFlags.DeclaredOnly — excludes them + // wholesale, and that is both necessary and too much: necessary because + // DbContext implements IInfrastructure, so every + // module's DbContext was flagged the moment the first one existed, for + // something EF declares and no module author wrote; too much because it + // also stops seeing a module type that inherits a bus-shaped surface from + // a module base, which is the same violation with one extra hop. + // + // The declaring assembly separates the two. A member declared in the + // type's own assembly, or in any other module assembly, is the module's + // business; one declared in EF Core or the SharedKernel is not. const BindingFlags members = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public - | BindingFlags.NonPublic - | BindingFlags.DeclaredOnly; + | BindingFlags.NonPublic; - return type.GetConstructors(members).Any(constructor => + bool InScope(MemberInfo member) => + member.DeclaringType is null + || member.DeclaringType.Assembly == type.Assembly + || ModuleAssemblyShapes.Contains( + member.DeclaringType.Assembly.GetName().Name, StringComparer.Ordinal); + + return type.GetConstructors(members).Where(InScope).Any(constructor => constructor.GetParameters().Any(parameter => forbidden.Any(candidate => candidate.IsAssignableFrom(parameter.ParameterType)))) - || type.GetMethods(members).Any(method => + || type.GetMethods(members).Where(InScope).Any(method => forbidden.Any(candidate => candidate.IsAssignableFrom(method.ReturnType)) || method.GetParameters().Any(parameter => forbidden.Any(candidate => candidate.IsAssignableFrom(parameter.ParameterType)))) - || type.GetFields(members).Any(field => + || type.GetFields(members).Where(InScope).Any(field => forbidden.Any(candidate => candidate.IsAssignableFrom(field.FieldType))) - || type.GetProperties(members).Any(property => + || type.GetProperties(members).Where(InScope).Any(property => forbidden.Any(candidate => candidate.IsAssignableFrom(property.PropertyType))); } @@ -515,6 +541,20 @@ private sealed class DeliberateEventBusServiceLocator(IServiceProvider services) public IServiceProvider Services { get; } = services; } + /// A base whose bus-shaped surface a derived type inherits. + private class DeliberateEventBusBase + { + protected LearnStack.SharedKernel.Messaging.IEventBus Bus => + throw new NotSupportedException("shape only"); + } + + /// + /// A deliberate offender that declares nothing at all and inherits the + /// violation, so the declaring-assembly filter cannot quietly widen back into + /// BindingFlags.DeclaredOnly. + /// + private sealed class DeliberateInheritingPublisher : DeliberateEventBusBase; + /// A method-injection-shaped deliberate offender. private sealed class DeliberateMethodPublisher { diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs new file mode 100644 index 00000000..d72f0768 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -0,0 +1,118 @@ +using System.Xml.Linq; +using FluentAssertions; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +/// +/// The persistence rules +/// ADR-0039 +/// and +/// ADR-0040 +/// assign to Packet 6, catalogued in +/// Standards 21 +/// § Persistence: concurrency and the unit of work. +/// +/// +/// Model inspection rather than a source scan. The mistake these rules exist to +/// catch is not a forbidden call site — it is EF metadata that looks right at the +/// call site and is wrong in the model, which is exactly what a scan cannot see. +/// +public sealed class PersistenceConventionTests +{ + [Fact] + public void Aggregates_With_Optimistic_Concurrency_Map_RowVersion() + { + // Three properties of the metadata, not one, because the two ways of + // getting this wrong fail different ones: + // + // IsRowVersion() / ValueGeneratedOnAddOrUpdate() leave both save + // behaviours at Ignore, and EF then omits row_version from the UPDATE + // entirely — the token stays 0 for the life of the row and every lost + // update succeeds while reporting success (ADR-0039 Amendment 1). + // + // HasDefaultValue(0L) — needed for the DDL template's DEFAULT 0 — leaves + // ValueGenerated at OnAdd on its own. That one is benign today and is + // still rejected: it is a store-generated declaration on a column the + // aggregate increments, and the next reader of the model has to work out + // which of the two mistakes it is. `.ValueGeneratedNever()` states it + // (ADR-0039 Amendment 2). + using var context = BuildTenancyContext(); + + var offenders = new List(); + + foreach (var entity in context.Model.GetEntityTypes()) + { + if (!typeof(IOptimisticConcurrency).IsAssignableFrom(entity.ClrType)) + { + continue; + } + + var version = entity.FindProperty(nameof(IOptimisticConcurrency.Version)); + + if (version is null + || version.GetColumnName() != "row_version" + || !version.IsConcurrencyToken + || version.ValueGenerated != ValueGenerated.Never + || version.GetBeforeSaveBehavior() != PropertySaveBehavior.Save + || version.GetAfterSaveBehavior() != PropertySaveBehavior.Save) + { + offenders.Add( + $"{entity.ClrType.Name}: column={version?.GetColumnName() ?? ""} " + + $"token={version?.IsConcurrencyToken} valueGenerated={version?.ValueGenerated} " + + $"before={version?.GetBeforeSaveBehavior()} after={version?.GetAfterSaveBehavior()}"); + } + } + + offenders.Should().BeEmpty( + "row_version is IsConcurrencyToken() + ValueGeneratedNever(), and nothing " + + "that tells EF the database generates it (ADR-0039; Standards 05 § Concurrency)"); + + // A model with no IOptimisticConcurrency entity would pass the loop above + // without inspecting anything, which is the same defect as an inclusion + // list that matches nothing. + context.Model.GetEntityTypes() + .Count(e => typeof(IOptimisticConcurrency).IsAssignableFrom(e.ClrType)) + .Should().BeGreaterThan(0, "the rule must be reading a model that has aggregates in it"); + } + + [Fact] + public void Migration_Startup_Project_References_EntityFrameworkCore_Design() + { + // `dotnet ef` resolves the design package from the STARTUP project, and + // `make migrate` names LearnStack.Api. Without the reference the tool + // refuses before it opens a connection — "Your startup project + // 'LearnStack.Api' doesn't reference Microsoft.EntityFrameworkCore.Design" + // — and Packet 6 shipped a migration in exactly that state: green under + // Testcontainers, which calls Database.MigrateAsync() directly, and + // inapplicable by the one path Standards 05 § Database roles documents. + var startupProject = Path.Combine( + RepositoryPaths.BackendSrc(), "LearnStack.Api", "LearnStack.Api.csproj"); + + var references = XDocument.Load(startupProject) + .Descendants("PackageReference") + .Select(element => element.Attribute("Include")?.Value) + .ToList(); + + references.Should().Contain("Microsoft.EntityFrameworkCore.Design", + "`make migrate` passes --startup-project backend/src/LearnStack.Api, and " + + "dotnet ef resolves the design-time package from there"); + } + + /// + /// Builds the Tenancy model without a database. + /// + /// + /// A connection string is required to configure the provider and is never + /// opened: DbContext.Model is built from the configurations alone. The + /// value is deliberately not a real credential. + /// + private static TenancyDbContext BuildTenancyContext() => + new(new DbContextOptionsBuilder() + .UseNpgsql("Host=model-only;Database=model-only;Username=model-only") + .Options); +} diff --git a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs index 423d15c8..4cf8dbe9 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs @@ -98,6 +98,50 @@ public void Assertion_Budget_Does_Not_Depend_On_ICacheService() + "(ADR-0036 § Recording a rejected assertion)"); } + [Theory] + [InlineData("Organization")] + [InlineData("OrganizationBranding")] + public void Organization_Aggregate_Declared_In_Tenancy_Domain(string typeName) + { + // ADR-0017's original sample put Organization in Identity; Amendment 2 + // moved it to Tenancy, and Identity now holds OrganizationId by value and + // reads organization data through an application contract. A second + // declaration is how the two drift back apart. + // + // The assembly set is ENUMERATED rather than discovered. A rule that + // scanned loaded assemblies would silently skip the module nobody + // referenced, and pass vacuously — the failure + // Meta_NetArchTest_DetectsAPlantedViolation guards against generally. + // + // OrganizationBranding does not exist yet (Phase 06 ships it with the + // token merge). The rule still runs: "exactly one, in Tenancy" is + // satisfied by none as well as by one, and stating it now is what stops + // the first one landing in the wrong module. + var declarations = ModuleDomainAssemblies() + .SelectMany(assembly => assembly.GetTypes() + .Where(type => type.Name == typeName) + .Select(type => $"{type.FullName} in {assembly.GetName().Name}")) + .ToList(); + + declarations.Should().HaveCountLessThanOrEqualTo(1, + $"{typeName} is declared once across every module Domain assembly (ADR-0017 Amendment 2)"); + + declarations + .Where(d => !d.EndsWith("LearnStack.Modules.Tenancy.Domain", StringComparison.Ordinal)) + .Should().BeEmpty($"and Tenancy is where {typeName} is declared"); + } + + /// + /// The Domain assembly of every module, by name. + /// + private static IEnumerable ModuleDomainAssemblies() => + ModuleNames.Select(module => Assembly.Load($"LearnStack.Modules.{module}.Domain")); + + private static readonly string[] ModuleNames = + [ + "Tenancy", "Identity", "Customization", "Audit", "Content", "Media", "Education", + ]; + /// /// Types in the LearnStack.Api.Tenancy namespace that take an /// as a diff --git a/backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs new file mode 100644 index 00000000..be4cafa0 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs @@ -0,0 +1,125 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// Both migration chains applied and then reversed against a real database. +/// +/// +/// +/// Database Standards § Migrations says reversal is expected for a non-destructive +/// migration, and both chains here only create. Neither reversed as first shipped: +/// the tenancy Down() aborted on its first statement, because +/// DROP FUNCTION fn_organization_id_immutable() fails while the trigger on +/// tenant_settings depends on it, and would have aborted again on +/// DROP TABLE organizations, which three foreign keys reference. +/// DropTable emits a bare DROP TABLE with no CASCADE, so the +/// alphabetical order EF scaffolds is not a working order. +/// +/// +/// The point of the case is that it is measured rather than reasoned about. A +/// comment asserting the ordering is correct is exactly what shipped the broken +/// one. +/// +/// +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class MigrationRollbackTests : IClassFixture +{ + private readonly MigrationRollbackFixture _fixture; + + public MigrationRollbackTests(MigrationRollbackFixture fixture) => _fixture = fixture; + + [Fact] + public async Task BothChainsReverseToAnEmptySchema() + { + await using var connection = await PostgresFixture.OpenAsync( + _fixture.Postgres.MigrationConnectionString); + + // Applied state first, so a rollback that reversed nothing because nothing + // was there cannot pass. + (await CountAsync(connection, TablesQuery)).Should().Be(12L, + "eight tenancy tables, two platform tables, and the two history tables"); + (await CountAsync(connection, FunctionQuery)).Should().Be(1L, + "fn_organization_id_immutable backs the tenant_settings trigger"); + + await _fixture.RollBackAsync(); + + // The history tables survive: `database update 0` empties them, it does not + // drop them. Everything the two migrations created is gone. + (await CountAsync(connection, TablesQuery)).Should().Be(2L); + (await CountAsync(connection, FunctionQuery)).Should().Be(0L); + (await CountAsync(connection, PolicyQuery)).Should().Be(0L); + } + + private const string TablesQuery = + "SELECT count(*) FROM pg_class WHERE relnamespace = 'public'::regnamespace AND relkind = 'r'"; + + private const string FunctionQuery = + "SELECT count(*) FROM pg_proc WHERE proname = 'fn_organization_id_immutable'"; + + private const string PolicyQuery = + "SELECT count(*) FROM pg_policies WHERE schemaname = 'public'"; + + private static async Task CountAsync(System.Data.Common.DbConnection connection, string sql) + { + await using var command = new NpgsqlCommand(sql, (NpgsqlConnection)connection); + return (long)(await command.ExecuteScalarAsync())!; + } +} + +/// +/// Its own container: this fixture destroys the schema, so it cannot share one +/// with the cases that read it. +/// +public sealed class MigrationRollbackFixture : IAsyncLifetime +{ + public PostgresFixture Postgres { get; } = new(); + + public async Task InitializeAsync() + { + await Postgres.InitializeAsync(); + + await using var tenancy = CreateTenancy(); + await tenancy.Database.MigrateAsync(); + + await using var platform = CreatePlatform(); + await platform.Database.MigrateAsync(); + } + + public async Task DisposeAsync() => await Postgres.DisposeAsync(); + + /// + /// Reverses both chains, platform first — the order a developer undoing a + /// packet would use, and the one that proves neither chain depends on the + /// other's tables. + /// + public async Task RollBackAsync() + { + await using (var platform = CreatePlatform()) + { + await platform.GetService().MigrateAsync(Migration.InitialDatabase); + } + + await using var tenancy = CreateTenancy(); + await tenancy.GetService().MigrateAsync(Migration.InitialDatabase); + } + + private TenancyDbContext CreateTenancy() => + new(new DbContextOptionsBuilder() + .UseNpgsql(Postgres.MigrationConnectionString, npgsql => + npgsql.MigrationsHistoryTable("__ef_migrations_history_tenancy")) + .Options); + + private PlatformDbContext CreatePlatform() => + new(new DbContextOptionsBuilder() + .UseNpgsql(Postgres.MigrationConnectionString, npgsql => + npgsql.MigrationsHistoryTable("__ef_migrations_history_platform")) + .Options); +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs index 22a696b5..6b9e79e1 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs @@ -12,27 +12,38 @@ namespace LearnStack.Tests.Integration.Database; /// /// /// -/// Every case connects as learnstack_app. A test that connected as -/// the owner — or as either BYPASSRLS role — would pass with every policy +/// Every data case connects as learnstack_app. A test that connected +/// as the owner — or as either BYPASSRLS role — would pass with every policy /// inert and prove nothing, which is the failure mode /// ADR-0003 -/// Amendment 3 names by hand. The one exception is the structural sweep, -/// which reads catalogue tables rather than data. +/// Amendment 3 names by hand. Four cases connect as +/// learnstack_migration: three read pg_catalog / +/// information_schema rather than data, and the fourth +/// () asserts a denial that +/// only the owner can experience. /// /// -/// These are the four cases that amendment lists as the minimum, plus the two -/// this schema adds: the organization-id immutability trigger, and the owner -/// being denied on the platform-scoped table. +/// Every table carries rows for both tenants. A count assertion against an +/// empty table passes whether or not the policy exists — that is how the first +/// version of the owner-denial case shipped proving nothing. The seed therefore +/// fills all eight, tenant A with a second organization so the organization half +/// of the template has something to hide, and every read case sweeps the whole +/// catalogue rather than a hand-written list. +/// +/// +/// Three method names are not house style — +/// TenantWide_Row_Of_TenantB_Is_Invisible_To_TenantA, +/// Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck and +/// Org_X_cannot_read_Org_Y_within_TenantA — because they are the canonical +/// identifiers in +/// the +/// architecture-test catalogue and the Phase 02a document. One rule, one +/// spelling. /// /// [Trait(RequiresDocker.Key, RequiresDocker.Value)] public sealed class TenancySchemaTests : IClassFixture { - private static readonly Guid TenantA = Guid.Parse("11111111-1111-7111-8111-111111111111"); - private static readonly Guid TenantB = Guid.Parse("22222222-2222-7222-8222-222222222222"); - private static readonly Guid OrgA = Guid.Parse("aaaaaaaa-1111-7111-8111-111111111111"); - private static readonly Guid SettingA = Guid.Parse("11111111-aaaa-7111-8111-111111111111"); - private readonly TenancySchemaFixture _schema; public TenancySchemaTests(TenancySchemaFixture schema) => _schema = schema; @@ -40,117 +51,151 @@ public sealed class TenancySchemaTests : IClassFixture [Fact] public async Task EveryTableEnablesAndForcesRowLevelSecurity() { - // No exception list, deliberately: a table that needed one would be a - // table nobody had classified, and the three classes in Database - // Standards § Table classes cover all eight. + // The catalogue itself, not a list of names kept beside it. An inclusion + // list fails open for the table somebody adds and forgets to add here — + // reproduced: a table created outside the array was invisible to this + // sweep and to the snake-case sweep at once, with row security off and a + // PascalCase column. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); - await using var command = new NpgsqlCommand( - """ - SELECT relname FROM pg_class - WHERE relname = ANY(@tables) AND NOT (relrowsecurity AND relforcerowsecurity) - """, (NpgsqlConnection)connection); - command.Parameters.AddWithValue("tables", TenancySchemaFixture.Tables); - var unprotected = new List(); - await using (var reader = await command.ExecuteReaderAsync()) - { - while (await reader.ReadAsync()) - { - unprotected.Add(reader.GetString(0)); - } - } + var scanned = await ReadStringsAsync(connection, TableCatalogueQuery); + scanned.Should().Contain(TenancySchemaFixture.KnownTables, + "the sweep must be reading the tenancy schema, not an empty result set"); + + var unprotected = await ReadStringsAsync(connection, + $""" + SELECT relname FROM pg_class + WHERE oid IN ({TableCatalogueOids}) AND NOT (relrowsecurity AND relforcerowsecurity) + """); unprotected.Should().BeEmpty("ENABLE without FORCE lets the owner bypass its own policies"); } [Fact] - public async Task NoTenantOwnedTableCarriesASecondPermissivePolicy() + public async Task NoTableCarriesTwoPermissivePoliciesForOneCommand() { // The defect ADR-0003 Amendment 3 corrects. CREATE POLICY is permissive by // default and PostgreSQL combines permissive policies with OR, so a second // one WIDENS access — it made every tenant-wide row visible to every - // tenant. Only platform_host_to_tenant carries several, and they are - // per-command (SELECT/INSERT/UPDATE/DELETE), which do not combine. + // tenant. + // + // Grouped by (table, command) rather than excluding platform_host_to_tenant + // by name. That table legitimately carries four permissive policies, one + // per command, and naming it here made the one table whose SELECT policy is + // deliberately wide the one table this guard could not see: a second + // permissive SELECT on it passed the excluded form while leaking every + // tenant's host mappings to any session with any tenant context. + // + // `FOR ALL` is caught separately because its cmd is 'ALL' — it overlaps + // every per-command policy, so a table holding one alongside any other + // permissive policy has two policies for at least one command. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); - await using var command = new NpgsqlCommand( + + var offenders = await ReadStringsAsync(connection, """ - SELECT tablename, count(*) + SELECT tablename || ' (' || string_agg(policyname, ', ' ORDER BY policyname) || ')' FROM pg_policies - WHERE schemaname = 'public' - AND permissive = 'PERMISSIVE' - AND tablename <> 'platform_host_to_tenant' - GROUP BY tablename HAVING count(*) > 1 - """, (NpgsqlConnection)connection); - - await using var reader = await command.ExecuteReaderAsync(); - - (await reader.ReadAsync()).Should().BeFalse( - "a second permissive policy is OR-ed with the first and widens access"); + WHERE schemaname = 'public' AND permissive = 'PERMISSIVE' + GROUP BY tablename + HAVING count(*) > 1 + AND (bool_or(cmd = 'ALL') OR count(*) <> count(DISTINCT cmd)) + """); + + offenders.Should().BeEmpty( + "permissive policies covering the same command are OR-ed, and widen access"); } [Fact] - public async Task WithNoTenantContextEveryTenantOwnedTableReturnsZeroRows() + public async Task Unsetting_tenant_context_returns_zero_rows_through_RLS() { // Fail-closed, and it is the NULLIF that delivers it: an unset dotted GUC // reads as the empty string on a pooled connection, and ''::uuid RAISES // rather than filtering. NULLIF makes it NULL, and a NULL predicate is // false for USING and WITH CHECK alike. + // + // Swept over every table rather than asserted on `tenants` alone. Each of + // the eight holds rows for both tenants, so a table whose policy lost its + // predicate answers with a non-zero count here. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); - await using var command = new NpgsqlCommand("SELECT count(*) FROM tenants", (NpgsqlConnection)connection); - (await command.ExecuteScalarAsync()).Should().Be(0L); + var counts = await CountEveryTableAsync(connection, transaction: null); + + counts.Should().OnlyContain(entry => entry.Value == 0, + "with no app.tenant_id every policy predicate is NULL, which is false"); + counts.Keys.Should().Contain(TenancySchemaFixture.KnownTables); } [Fact] - public async Task ATenantSeesItsOwnRowsAndOnlyThose() + public async Task Tenant_A_cannot_read_Tenant_B_data() { + // Both tenants hold rows in all eight tables, and tenant A holds a second + // organization, so every count here is a number only the correct policy + // produces. A policy widened to USING (true) — the mutation that survived + // the first version of this suite — shows up as A seeing B's rows. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); - await using var transaction = await connection.BeginTransactionAsync(); - await SetTenantAsync(connection, transaction, TenantA); - await using var command = new NpgsqlCommand( - "SELECT (SELECT count(*) FROM tenants), (SELECT count(*) FROM organizations)", - (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + await using (var transaction = await connection.BeginTransactionAsync()) + { + await SetTenantAsync(connection, transaction, TenancySchemaFixture.TenantA); + var seenByA = await CountEveryTableAsync(connection, transaction); - await using var reader = await command.ExecuteReaderAsync(); - await reader.ReadAsync(); + seenByA.Should().BeEquivalentTo(TenancySchemaFixture.RowsVisibleToTenantA); + } + + await using (var transaction = await connection.BeginTransactionAsync()) + { + await SetTenantAsync(connection, transaction, TenancySchemaFixture.TenantB); + var seenByB = await CountEveryTableAsync(connection, transaction); - reader.GetInt64(0).Should().Be(1); - reader.GetInt64(1).Should().Be(1); + seenByB.Should().BeEquivalentTo(TenancySchemaFixture.RowsVisibleToTenantB); + } } [Fact] - public async Task ATenantWideRowOfOneTenantIsInvisibleToAnother() + public async Task TenantWide_Row_Of_TenantB_Is_Invisible_To_TenantA() { // THE case the superseded template leaked. Its two permissive policies // were OR-ed, so a row with organization_id IS NULL satisfied the - // organization half on its own — visible to every tenant. + // organization half on its own — visible to every tenant. Asserted on the + // key rather than on a count, so it names the row it is looking for. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); await using var transaction = await connection.BeginTransactionAsync(); - await SetTenantAsync(connection, transaction, TenantB); + await SetTenantAsync(connection, transaction, TenancySchemaFixture.TenantA); await using var command = new NpgsqlCommand( - "SELECT count(*) FROM tenant_settings", + "SELECT count(*) FROM tenant_settings WHERE key = 'beta-only' AND organization_id IS NULL", (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); (await command.ExecuteScalarAsync()).Should().Be(0L, - "tenant A's tenant-wide setting must not be visible to tenant B"); + "tenant B's tenant-wide setting must not be visible to tenant A"); } - [Fact] - public async Task AWriteNamingAForeignTenantIsRefused() + [Theory] + [InlineData( + """ + INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) + VALUES (uuidv7(), @foreign, 'sneak', 'Sneak', 'Active', now(), @actor, 0) + """)] + [InlineData("UPDATE organizations SET tenant_id = @foreign WHERE tenant_id <> @foreign")] + public async Task Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck(string statement) { + // Both halves, because WITH CHECK guards both and a USING-only policy + // would pass the read-side cases while leaving either write open. The + // UPDATE runs under tenant B's context against tenant B's own row and + // tries to hand it to tenant A: USING admits the row, WITH CHECK refuses + // the new value. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); await using var transaction = await connection.BeginTransactionAsync(); - await SetTenantAsync(connection, transaction, TenantB); + await SetTenantAsync(connection, transaction, TenancySchemaFixture.TenantB); await using var command = new NpgsqlCommand( - """ - INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) - VALUES (gen_random_uuid(), @foreign, 'sneak', 'Sneak', 'Active', now(), @actor, 0) - """, (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); - command.Parameters.AddWithValue("foreign", TenantA); - command.Parameters.AddWithValue("actor", Guid.Parse("00000000-0000-7000-8000-000000000001")); + statement, (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + command.Parameters.AddWithValue("foreign", TenancySchemaFixture.TenantA); + + if (statement.Contains("@actor", StringComparison.Ordinal)) + { + command.Parameters.AddWithValue("actor", TenancySchemaFixture.Actor); + } var act = async () => await command.ExecuteNonQueryAsync(); @@ -158,6 +203,52 @@ INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at .Which.SqlState.Should().Be("42501", "WITH CHECK refuses a row outside the caller's tenant"); } + [Fact] + public async Task Org_X_cannot_read_Org_Y_within_TenantA() + { + // The organization half of the template, on the one org-scoped table this + // packet ships. Tenant A holds a setting under each of its two + // organizations; a session scoped to the first must see its own and the + // tenant-wide row, and neither see nor touch the second's. + // + // The three assertions are the three gates: USING for the read, the + // restrictive UPDATE guard, and the restrictive DELETE guard — DELETE has + // no WITH CHECK in PostgreSQL, so USING is its only gate and a widened one + // would let a sibling organization's row be removed. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SetTenantAsync(connection, transaction, TenancySchemaFixture.TenantA); + await SetSettingAsync(connection, transaction, + "app.organization_id", TenancySchemaFixture.OrgA1.ToString()); + + await using (var read = new NpgsqlCommand( + "SELECT count(*) FROM tenant_settings WHERE organization_id = @other", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction)) + { + read.Parameters.AddWithValue("other", TenancySchemaFixture.OrgA2); + (await read.ExecuteScalarAsync()).Should().Be(0L, + "organization A1 must not read organization A2's rows"); + } + + await using (var update = new NpgsqlCommand( + "UPDATE tenant_settings SET value = '\"hijacked\"' WHERE organization_id = @other", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction)) + { + update.Parameters.AddWithValue("other", TenancySchemaFixture.OrgA2); + (await update.ExecuteNonQueryAsync()).Should().Be(0, + "tenant_settings_org_write_guard is restrictive and admits no sibling row"); + } + + await using (var delete = new NpgsqlCommand( + "DELETE FROM tenant_settings WHERE organization_id = @other", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction)) + { + delete.Parameters.AddWithValue("other", TenancySchemaFixture.OrgA2); + (await delete.ExecuteNonQueryAsync()).Should().Be(0, + "tenant_settings_org_delete_guard is the only gate DELETE has"); + } + } + [Fact] public async Task OrganizationIdIsImmutableAfterInsert() { @@ -167,14 +258,14 @@ public async Task OrganizationIdIsImmutableAfterInsert() // organization_id is the caller's own, which is exactly this move. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); await using var transaction = await connection.BeginTransactionAsync(); - await SetTenantAsync(connection, transaction, TenantA); - await SetSettingAsync(connection, transaction, "app.organization_id", OrgA.ToString()); + await SetTenantAsync(connection, transaction, TenancySchemaFixture.TenantA); + await SetSettingAsync(connection, transaction, + "app.organization_id", TenancySchemaFixture.OrgA1.ToString()); await using var command = new NpgsqlCommand( - "UPDATE tenant_settings SET organization_id = @org WHERE id = @id", + "UPDATE tenant_settings SET organization_id = @org WHERE key = 'tz'", (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); - command.Parameters.AddWithValue("org", OrgA); - command.Parameters.AddWithValue("id", SettingA); + command.Parameters.AddWithValue("org", TenancySchemaFixture.OrgA1); var act = async () => await command.ExecuteNonQueryAsync(); @@ -190,11 +281,83 @@ public async Task TheOwnerIsDeniedOnThePlatformScopedTable() // rather than a bypass. Rows arrive through learnstack_app under tenant // context, or through learnstack_platform. Stated as a test because it // reads like a mistake and is the design. - await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); + // + // The BYPASSRLS role is read first, and that is what makes this an + // assertion rather than a tautology: the earlier version of this case read + // only the owner, on a table the fixture never populated, and passed with + // every policy dropped and row security disabled. + await using var platform = await PostgresFixture.OpenAsync(_schema.Postgres.PlatformConnectionString); + await using var truth = new NpgsqlCommand( + "SELECT count(*) FROM platform_host_to_tenant", (NpgsqlConnection)platform); + + (await truth.ExecuteScalarAsync()).Should().Be(2L, "the fixture seeds one mapping per tenant"); + + await using var owner = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); + await using var denied = new NpgsqlCommand( + "SELECT count(*) FROM platform_host_to_tenant", (NpgsqlConnection)owner); + + (await denied.ExecuteScalarAsync()).Should().Be(0L, + "no policy is qualified to the owner, and FORCE turns that into a denial"); + } + + [Fact] + public async Task TheResolvingHostAdmitsExactlyItsOwnRowBeforeAnyTenantContext() + { + // The read IHostToTenantResolver actually performs: no tenant context yet, + // because reading this row is how the tenant is determined. The policy + // admits the announced host and nothing else, so the wide SELECT clause is + // wide by exactly one row. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SetSettingAsync(connection, transaction, "app.resolving_host", TenancySchemaFixture.HostA); + await using var command = new NpgsqlCommand( - "SELECT count(*) FROM platform_host_to_tenant", (NpgsqlConnection)connection); + "SELECT string_agg(host, ',' ORDER BY host) FROM platform_host_to_tenant", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + + (await command.ExecuteScalarAsync()).Should().Be(TenancySchemaFixture.HostA); + } + + [Fact] + public async Task ASoftDeletedDomainReleasesItsHostToAnotherTenant() + { + // ux_tenant_domains_host is partial on `deleted_at IS NULL`. Without the + // predicate a released claim keeps the hostname for every other tenant + // forever — and the error crosses a tenant boundary, because PostgreSQL + // enforces unique indexes with row security bypassed, so it doubles as an + // oracle for a row the second tenant cannot see. + // + // One transaction, rolled back: the two GUC assignments are transaction + // local, so switching tenants inside it is legal and the fixture's row + // counts are left as the other cases expect them. + const string Host = "released.example.com"; + + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + + await SetTenantAsync(connection, transaction, TenancySchemaFixture.TenantA); + await ExecuteAsync(connection, transaction, + """ + INSERT INTO tenant_domains + (id, tenant_id, host, kind, status, verification_attempts, created_at, created_by, row_version) + VALUES (uuidv7(), @tenant, @host, 'Custom', 'Requested', 0, now(), @actor, 0) + """, + ("tenant", TenancySchemaFixture.TenantA), ("host", Host), ("actor", TenancySchemaFixture.Actor)); + + await ExecuteAsync(connection, transaction, + "UPDATE tenant_domains SET deleted_at = now(), deleted_by = @actor WHERE host = @host", + ("actor", TenancySchemaFixture.Actor), ("host", Host)); + + await SetTenantAsync(connection, transaction, TenancySchemaFixture.TenantB); + var reclaim = async () => await ExecuteAsync(connection, transaction, + """ + INSERT INTO tenant_domains + (id, tenant_id, host, kind, status, verification_attempts, created_at, created_by, row_version) + VALUES (uuidv7(), @tenant, @host, 'Custom', 'Requested', 0, now(), @actor, 0) + """, + ("tenant", TenancySchemaFixture.TenantB), ("host", Host), ("actor", TenancySchemaFixture.Actor)); - (await command.ExecuteScalarAsync()).Should().Be(0L); + await reclaim.Should().NotThrowAsync(); } [Fact] @@ -203,63 +366,152 @@ public async Task EveryMappedIdentifierIsSnakeCase() // Every policy predicate, every GRANT and every index name in Database // Standards is written against snake_case identifiers, so one PascalCase // column is a column the policy does not mention and the grant does not - // cover. + // cover. Swept over the catalogue for the same reason the row-security + // sweep is: a table missing from a hand-written list is a table nobody + // checks. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); - await using var command = new NpgsqlCommand( - """ - SELECT table_name || '.' || column_name - FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = ANY(@tables) AND column_name <> lower(column_name) - """, (NpgsqlConnection)connection); - command.Parameters.AddWithValue("tables", TenancySchemaFixture.Tables); - - var offenders = new List(); - await using (var reader = await command.ExecuteReaderAsync()) - { - while (await reader.ReadAsync()) - { - offenders.Add(reader.GetString(0)); - } - } + + var scanned = await ReadStringsAsync(connection, TableCatalogueQuery); + scanned.Should().Contain(TenancySchemaFixture.KnownTables); + + var offenders = await ReadStringsAsync(connection, + $""" + SELECT c.relname || '.' || a.attname + FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid + WHERE c.oid IN ({TableCatalogueOids}) + AND a.attnum > 0 AND NOT a.attisdropped + AND a.attname <> lower(a.attname) + """); offenders.Should().BeEmpty(); } [Fact] - public async Task TheApplicationRoleHoldsExactlyTheGrantsTheMatrixNames() + public async Task TheGrantMatrixIsExactlyWhatTheMigrationWrote() { // There is no ALTER DEFAULT PRIVILEGES, so every grant is one the migration - // wrote. Asserted table by table because the matrix is the whole of the - // bound on the bypass roles, and a widened grant is invisible otherwise. + // wrote. All three grantees are asserted, not just the application role: + // BYPASSRLS bypasses policies and not GRANTs, so for learnstack_platform + // and learnstack_outbox_admin this matrix is the whole of the bound, and a + // widened grant on either is invisible in any other assertion. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); - await using var command = new NpgsqlCommand( + + var grants = await ReadStringsAsync(connection, """ - SELECT table_name, string_agg(privilege_type, ',' ORDER BY privilege_type) + SELECT grantee || ' ' || table_name || ' ' || string_agg(privilege_type, ',' ORDER BY privilege_type) FROM information_schema.role_table_grants - WHERE grantee = 'learnstack_app' AND table_schema = 'public' - GROUP BY table_name ORDER BY table_name - """, (NpgsqlConnection)connection); + WHERE table_schema = 'public' + AND grantee IN ('learnstack_app', 'learnstack_platform', 'learnstack_outbox_admin') + GROUP BY grantee, table_name + """); + + grants.Should().BeEquivalentTo( + [ + "learnstack_app tenants INSERT,SELECT,UPDATE", + "learnstack_app organizations DELETE,INSERT,SELECT,UPDATE", + "learnstack_app tenant_domains DELETE,INSERT,SELECT,UPDATE", + "learnstack_app tenant_locales DELETE,INSERT,SELECT,UPDATE", + "learnstack_app tenant_settings DELETE,INSERT,SELECT,UPDATE", + "learnstack_app tenant_feature_flags DELETE,INSERT,SELECT,UPDATE", + "learnstack_app platform_entitlement_cache INSERT,SELECT,UPDATE", + "learnstack_app platform_host_to_tenant DELETE,INSERT,SELECT,UPDATE", + "learnstack_platform tenants DELETE,INSERT,SELECT,UPDATE", + "learnstack_platform organizations DELETE,INSERT,SELECT,UPDATE", + "learnstack_platform tenant_domains SELECT", + "learnstack_platform tenant_locales SELECT", + "learnstack_platform tenant_settings SELECT", + "learnstack_platform tenant_feature_flags DELETE,INSERT,SELECT,UPDATE", + "learnstack_platform platform_entitlement_cache DELETE,SELECT", + "learnstack_platform platform_host_to_tenant DELETE,INSERT,SELECT,UPDATE", + ], + "learnstack_outbox_admin owns nothing in the tenancy schema, and every other " + + "grant is one line of the migration's matrix"); + } - var grants = new Dictionary(StringComparer.Ordinal); - await using (var reader = await command.ExecuteReaderAsync()) + // ── Helpers ────────────────────────────────────────────────────────────── + + /// + /// Every ordinary table in schema public, minus EF's history table. + /// + /// + /// The only name written down, and it is written down because it is the one + /// table that is not part of the schema under test: it carries + /// MigrationId and ProductVersion, both PascalCase, and no row + /// security by design. + /// + private const string TableCatalogueOids = + """ + SELECT oid FROM pg_class + WHERE relnamespace = 'public'::regnamespace AND relkind = 'r' + AND relname NOT LIKE '\_\_ef%' + """; + + private const string TableCatalogueQuery = + """ + SELECT relname FROM pg_class + WHERE relnamespace = 'public'::regnamespace AND relkind = 'r' + AND relname NOT LIKE '\_\_ef%' + ORDER BY relname + """; + + private static async Task> CountEveryTableAsync( + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction? transaction) + { + var counts = new Dictionary(StringComparer.Ordinal); + + foreach (var table in await ReadStringsAsync(connection, TableCatalogueQuery, transaction)) { - while (await reader.ReadAsync()) - { - grants[reader.GetString(0)] = reader.GetString(1); - } + // The table name is a pg_class.relname read from the same connection + // moments earlier, not caller input; there is no bind parameter for an + // identifier, and quote_ident is what makes the interpolation safe. + await using var command = new NpgsqlCommand( + $"SELECT count(*) FROM {Quote(table)}", + (NpgsqlConnection)connection, (NpgsqlTransaction?)transaction); + + counts[table] = (long)(await command.ExecuteScalarAsync())!; } - grants.Should().BeEquivalentTo(new Dictionary(StringComparer.Ordinal) + return counts; + } + + private static string Quote(string identifier) => + "\"" + identifier.Replace("\"", "\"\"", StringComparison.Ordinal) + "\""; + + private static async Task> ReadStringsAsync( + System.Data.Common.DbConnection connection, + string sql, + System.Data.Common.DbTransaction? transaction = null) + { + await using var command = new NpgsqlCommand( + sql, (NpgsqlConnection)connection, (NpgsqlTransaction?)transaction); + + var values = new List(); + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) { - ["tenants"] = "INSERT,SELECT,UPDATE", - ["organizations"] = "DELETE,INSERT,SELECT,UPDATE", - ["tenant_domains"] = "DELETE,INSERT,SELECT,UPDATE", - ["tenant_locales"] = "DELETE,INSERT,SELECT,UPDATE", - ["tenant_settings"] = "DELETE,INSERT,SELECT,UPDATE", - ["tenant_feature_flags"] = "DELETE,INSERT,SELECT,UPDATE", - ["platform_entitlement_cache"] = "INSERT,SELECT,UPDATE", - ["platform_host_to_tenant"] = "DELETE,INSERT,SELECT,UPDATE", - }); + values.Add(reader.GetString(0)); + } + + return values; + } + + private static async Task ExecuteAsync( + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + string sql, + params (string Name, object Value)[] parameters) + { + await using var command = new NpgsqlCommand( + sql, (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + + foreach (var (name, value) in parameters) + { + command.Parameters.AddWithValue(name, value); + } + + + await command.ExecuteNonQueryAsync(); } private static Task SetTenantAsync( @@ -290,23 +542,82 @@ private static async Task SetSettingAsync( } /// -/// Applies the tenancy migration once and seeds the two tenants the isolation -/// cases compare. +/// Applies the tenancy migration once and seeds every table for both +/// tenants. /// /// -/// The seed runs as learnstack_migration inside a transaction that sets -/// app.tenant_id per tenant — the only way to insert, since every table's +/// +/// A count assertion against an empty table passes whether or not the policy that +/// should have emptied it exists, so a partial seed silently turns isolation cases +/// into tautologies. Every one of the eight tables therefore carries rows for +/// tenant A and tenant B, and tenant A carries a second organization so the +/// organization half of the template has a sibling row to hide. +/// +/// +/// Most of the seed runs as learnstack_migration inside a transaction that +/// sets app.tenant_id — the only way to insert, since every table's /// WITH CHECK is live from the moment the migration finishes. +/// platform_host_to_tenant is the exception: its policies are qualified +/// TO learnstack_app, so the owner is denied on it and its rows go in +/// through the application role. +/// /// public sealed class TenancySchemaFixture : IAsyncLifetime { - internal static readonly string[] Tables = + public static readonly Guid TenantA = Guid.Parse("11111111-1111-7111-8111-111111111111"); + public static readonly Guid TenantB = Guid.Parse("22222222-2222-7222-8222-222222222222"); + public static readonly Guid OrgA1 = Guid.Parse("aaaaaaaa-1111-7111-8111-111111111111"); + public static readonly Guid OrgA2 = Guid.Parse("aaaaaaaa-2222-7222-8222-222222222222"); + public static readonly Guid OrgB1 = Guid.Parse("bbbbbbbb-1111-7111-8111-111111111111"); + public static readonly Guid Actor = Guid.Parse("00000000-0000-7000-8000-000000000001"); + + public const string HostA = "alpha.example.com"; + public const string HostB = "beta.example.com"; + + /// + /// The eight tables this migration creates, used only to prove that a + /// catalogue sweep read something. + /// + /// + /// Not an inclusion list: no query filters on it. It exists so a sweep that + /// silently matched nothing fails instead of passing, which is the other way a + /// structural assertion can prove nothing. + /// + public static readonly string[] KnownTables = [ "tenants", "organizations", "tenant_domains", "tenant_locales", "tenant_settings", "tenant_feature_flags", "platform_entitlement_cache", "platform_host_to_tenant", ]; + /// What tenant A sees with its tenant context set and no organization scope. + public static readonly Dictionary RowsVisibleToTenantA = new(StringComparer.Ordinal) + { + ["tenants"] = 1, + ["organizations"] = 2, + ["tenant_domains"] = 1, + ["tenant_locales"] = 1, + // Three rows exist; two are organization-scoped and invisible without + // app.organization_id. Org_X_cannot_read_Org_Y_within_TenantA reads those. + ["tenant_settings"] = 1, + ["tenant_feature_flags"] = 1, + ["platform_entitlement_cache"] = 1, + ["platform_host_to_tenant"] = 1, + }; + + /// What tenant B sees with its tenant context set. + public static readonly Dictionary RowsVisibleToTenantB = new(StringComparer.Ordinal) + { + ["tenants"] = 1, + ["organizations"] = 1, + ["tenant_domains"] = 1, + ["tenant_locales"] = 1, + ["tenant_settings"] = 1, + ["tenant_feature_flags"] = 1, + ["platform_entitlement_cache"] = 1, + ["platform_host_to_tenant"] = 1, + }; + public PostgresFixture Postgres { get; } = new(); public async Task InitializeAsync() @@ -330,32 +641,125 @@ public async Task InitializeAsync() private async Task SeedAsync() { - await using var connection = await PostgresFixture.OpenAsync(Postgres.MigrationConnectionString); - await using var command = new NpgsqlCommand( - """ - BEGIN; - SET LOCAL app.tenant_id = '11111111-1111-7111-8111-111111111111'; - INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) - VALUES ('11111111-1111-7111-8111-111111111111','alpha','Alpha','Trial', now(), - '00000000-0000-7000-8000-000000000001', 0); - INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) - VALUES ('aaaaaaaa-1111-7111-8111-111111111111','11111111-1111-7111-8111-111111111111', - 'main','Main','Active', now(), '00000000-0000-7000-8000-000000000001', 0); - UPDATE tenants SET default_organization_id = 'aaaaaaaa-1111-7111-8111-111111111111' - WHERE id = '11111111-1111-7111-8111-111111111111'; - INSERT INTO tenant_settings (id, tenant_id, organization_id, key, value, created_at, created_by, row_version) - VALUES ('11111111-aaaa-7111-8111-111111111111','11111111-1111-7111-8111-111111111111', - NULL, 'tz', '"Europe/Istanbul"', now(), '00000000-0000-7000-8000-000000000001', 0); - COMMIT; - - BEGIN; - SET LOCAL app.tenant_id = '22222222-2222-7222-8222-222222222222'; - INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) - VALUES ('22222222-2222-7222-8222-222222222222','beta','Beta','Trial', now(), - '00000000-0000-7000-8000-000000000001', 0); - COMMIT; - """, (NpgsqlConnection)connection); + await using (var owner = await PostgresFixture.OpenAsync(Postgres.MigrationConnectionString)) + { + await using var command = new NpgsqlCommand(TenantRowsSql, (NpgsqlConnection)owner); + await command.ExecuteNonQueryAsync(); + } - await command.ExecuteNonQueryAsync(); + // platform_host_to_tenant only: its four policies are role-qualified TO + // learnstack_app, so under FORCE the owner is denied on it. + await using var app = await PostgresFixture.OpenAsync(Postgres.AppConnectionString); + await using var mappings = new NpgsqlCommand(HostMappingsSql, (NpgsqlConnection)app); + await mappings.ExecuteNonQueryAsync(); } + + private const string TenantRowsSql = + """ + BEGIN; + SET LOCAL app.tenant_id = '11111111-1111-7111-8111-111111111111'; + + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('11111111-1111-7111-8111-111111111111','alpha','Alpha','Trial', now(), + '00000000-0000-7000-8000-000000000001', 0); + + INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('aaaaaaaa-1111-7111-8111-111111111111','11111111-1111-7111-8111-111111111111', + 'main','Main','Active', now(), '00000000-0000-7000-8000-000000000001', 0), + ('aaaaaaaa-2222-7222-8222-222222222222','11111111-1111-7111-8111-111111111111', + 'branch','Branch','Active', now(), '00000000-0000-7000-8000-000000000001', 0); + + UPDATE tenants SET default_organization_id = 'aaaaaaaa-1111-7111-8111-111111111111' + WHERE id = '11111111-1111-7111-8111-111111111111'; + + INSERT INTO tenant_domains + (id, tenant_id, host, kind, status, verification_attempts, created_at, created_by, row_version) + VALUES (uuidv7(),'11111111-1111-7111-8111-111111111111','alpha.example.com','Subdomain','Verified', + 0, now(), '00000000-0000-7000-8000-000000000001', 0); + + INSERT INTO tenant_locales (tenant_id, locale, is_default, is_enabled, sort) + VALUES ('11111111-1111-7111-8111-111111111111','tr-TR', true, true, 0); + + INSERT INTO tenant_settings (id, tenant_id, organization_id, key, value, created_at, created_by, row_version) + VALUES (uuidv7(),'11111111-1111-7111-8111-111111111111', NULL, + 'tz', '"Europe/Istanbul"', now(), '00000000-0000-7000-8000-000000000001', 0); + + -- One organization at a time, because the org-scoped WITH CHECK admits a + -- row only under its own organization's context. Writing both in one + -- statement is exactly what the guard refuses, and the seed is the first + -- place that shows it. + SET LOCAL app.organization_id = 'aaaaaaaa-1111-7111-8111-111111111111'; + INSERT INTO tenant_settings (id, tenant_id, organization_id, key, value, created_at, created_by, row_version) + VALUES (uuidv7(),'11111111-1111-7111-8111-111111111111','aaaaaaaa-1111-7111-8111-111111111111', + 'theme', '"main"', now(), '00000000-0000-7000-8000-000000000001', 0); + + SET LOCAL app.organization_id = 'aaaaaaaa-2222-7222-8222-222222222222'; + INSERT INTO tenant_settings (id, tenant_id, organization_id, key, value, created_at, created_by, row_version) + VALUES (uuidv7(),'11111111-1111-7111-8111-111111111111','aaaaaaaa-2222-7222-8222-222222222222', + 'theme', '"branch"', now(), '00000000-0000-7000-8000-000000000001', 0); + + INSERT INTO tenant_feature_flags (tenant_id, key, value, updated_by) + VALUES ('11111111-1111-7111-8111-111111111111','live-classroom','true', + '00000000-0000-7000-8000-000000000001'); + + INSERT INTO platform_entitlement_cache + (tenant_id, plan_code, features, limits, compliance, valid_until, source) + VALUES ('11111111-1111-7111-8111-111111111111','pro','{}','{}','{}', + now() + interval '30 days','null-provider'); + COMMIT; + + BEGIN; + SET LOCAL app.tenant_id = '22222222-2222-7222-8222-222222222222'; + + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('22222222-2222-7222-8222-222222222222','beta','Beta','Trial', now(), + '00000000-0000-7000-8000-000000000001', 0); + + INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('bbbbbbbb-1111-7111-8111-111111111111','22222222-2222-7222-8222-222222222222', + 'main','Main','Active', now(), '00000000-0000-7000-8000-000000000001', 0); + + UPDATE tenants SET default_organization_id = 'bbbbbbbb-1111-7111-8111-111111111111' + WHERE id = '22222222-2222-7222-8222-222222222222'; + + INSERT INTO tenant_domains + (id, tenant_id, host, kind, status, verification_attempts, created_at, created_by, row_version) + VALUES (uuidv7(),'22222222-2222-7222-8222-222222222222','beta.example.com','Subdomain','Verified', + 0, now(), '00000000-0000-7000-8000-000000000001', 0); + + INSERT INTO tenant_locales (tenant_id, locale, is_default, is_enabled, sort) + VALUES ('22222222-2222-7222-8222-222222222222','en-US', true, true, 0); + + INSERT INTO tenant_settings (id, tenant_id, organization_id, key, value, created_at, created_by, row_version) + VALUES (uuidv7(),'22222222-2222-7222-8222-222222222222', NULL, + 'beta-only', '"visible to beta alone"', now(), + '00000000-0000-7000-8000-000000000001', 0); + + INSERT INTO tenant_feature_flags (tenant_id, key, value, updated_by) + VALUES ('22222222-2222-7222-8222-222222222222','live-classroom','false', + '00000000-0000-7000-8000-000000000001'); + + INSERT INTO platform_entitlement_cache + (tenant_id, plan_code, features, limits, compliance, valid_until, source) + VALUES ('22222222-2222-7222-8222-222222222222','free','{}','{}','{}', + now() + interval '30 days','null-provider'); + COMMIT; + """; + + private const string HostMappingsSql = + """ + BEGIN; + SET LOCAL app.tenant_id = '11111111-1111-7111-8111-111111111111'; + INSERT INTO platform_host_to_tenant (host, tenant_id, organization_id, is_active, is_publicly_live) + VALUES ('alpha.example.com','11111111-1111-7111-8111-111111111111', + 'aaaaaaaa-1111-7111-8111-111111111111', true, true); + COMMIT; + + BEGIN; + SET LOCAL app.tenant_id = '22222222-2222-7222-8222-222222222222'; + INSERT INTO platform_host_to_tenant (host, tenant_id, organization_id, is_active, is_publicly_live) + VALUES ('beta.example.com','22222222-2222-7222-8222-222222222222', + 'bbbbbbbb-1111-7111-8111-111111111111', true, true); + COMMIT; + """; } diff --git a/docs/architecture/02-domain-model.md b/docs/architecture/02-domain-model.md index 55e8c32a..117171b0 100644 --- a/docs/architecture/02-domain-model.md +++ b/docs/architecture/02-domain-model.md @@ -214,7 +214,7 @@ flowchart LR | Entity | Aggregate root? | Notes | |--------|-----------------|-------| -| `Tenant` | Yes | Global table; sits above `tenant_id` scoping. Status: Trial / Active / Suspended / Archived. | +| `Tenant` | Yes | Tenant-owned, **self-keyed**: no `tenant_id` column, because its `id` *is* the tenant id and its RLS policy keys on `id`. Status: Trial / Active / Suspended / Archived. | | `Organization` | Yes | Sub-unit within a tenant (branch, studio, campus, department, cohort). Two-level hierarchy strict (ADR-0017). Every tenant has at least one default org. | | `TenantDomain` | Inside Tenant | Subdomain on `{slug}.learnstack.app` (always available) or custom domain (Hub-managed; see [27-custom-domain-tls.md](27-custom-domain-tls.md)). | | `TenantBranding` | Inside Tenant | Logo, colors, typography tokens. May be overridden per-organization via `OrganizationBranding`. | diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index 87be8bbb..0f98c0bf 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -31,7 +31,7 @@ Out of scope for the initial implementation: -- in tenancy module CREATE TABLE tenant_locales ( tenant_id UUID NOT NULL, - locale TEXT NOT NULL, + locale VARCHAR(35) NOT NULL, -- BCP-47's maximum well-formed length is_default BOOLEAN NOT NULL, is_enabled BOOLEAN NOT NULL DEFAULT TRUE, sort SMALLINT NOT NULL DEFAULT 0, @@ -41,6 +41,10 @@ CREATE TABLE tenant_locales ( A tenant with no `tenant_locales` row falls back to the platform default (`en`). +The shipped table is the Tenancy module's migration, which adds the audit-free +composite primary key shown above plus `ENABLE`/`FORCE ROW LEVEL SECURITY` and the +tenant-wide policy; this fence is the column sketch, not the DDL. + ## Storage Schema: Translatable Fields Two patterns are used; the choice depends on the entity shape. diff --git a/docs/architecture/21-feature-flags.md b/docs/architecture/21-feature-flags.md index dfc588c6..539db248 100644 --- a/docs/architecture/21-feature-flags.md +++ b/docs/architecture/21-feature-flags.md @@ -109,7 +109,7 @@ Two tables, both in the Tenancy module schema: -- Tenant-level flag overrides (experimental, rollout, opt-in). CREATE TABLE tenant_feature_flags ( tenant_id uuid NOT NULL, - key text NOT NULL, + key varchar(200) NOT NULL, value jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now(), updated_by uuid NOT NULL, @@ -124,7 +124,7 @@ CREATE TABLE tenant_feature_flags ( -- INVALIDATION signal for the L1/L2 caches in front of it, not the write path. CREATE TABLE platform_entitlement_cache ( tenant_id uuid PRIMARY KEY, - plan_code text NOT NULL, -- wire field `tier` + plan_code varchar(100) NOT NULL, -- wire field `tier` features jsonb NOT NULL, -- Dictionary limits jsonb NOT NULL, -- Dictionary compliance jsonb NOT NULL, -- caps, regions, retention overrides @@ -137,6 +137,14 @@ CREATE TABLE platform_entitlement_cache ( ); ``` +The migration in `LearnStack.Modules.Tenancy.Infrastructure` is the source for these +two tables; the fences above are kept in step with it. The length caps on `key` and +`plan_code` are the migration's, and are a bound the bare `text` this document first +declared did not carry. `source` stays `text` because it is a closed set, and +[Database Standards § Column types](../standards/05-database.md) fixes `text` with a +`CHECK` as the form for those. Row-security clauses are in the migration and +deliberately not restated here. + The wire shape and the column names differ in two places, and the mapping is normative: the projection field `tier` persists to `plan_code`, and `expires_at` persists to `valid_until`. `grace_until` and `generation` keep their wire names. `grace_until` is diff --git a/docs/decisions/0039-optimistic-concurrency-token.md b/docs/decisions/0039-optimistic-concurrency-token.md index 43e5212d..21534e2b 100644 --- a/docs/decisions/0039-optimistic-concurrency-token.md +++ b/docs/decisions/0039-optimistic-concurrency-token.md @@ -179,8 +179,10 @@ routing `SoftDelete` through the same stamp-and-increment primitive. `AuditableEntity.Version` to `long`; route `SoftDelete` through the same stamp-and-increment primitive as `MarkUpdated`; declare `row_version bigint NOT NULL DEFAULT 0` on every mutable tenancy table and - configure it as the EF concurrency token with **`IsConcurrencyToken()` and - nothing else** — see Amendment 1, which measured what the other two forms do. + configure it as the EF concurrency token with + **`HasDefaultValue(0L).IsConcurrencyToken().ValueGeneratedNever()`** — see + Amendment 1 for what the two forbidden forms do, and Amendment 2 for why the + chain is three calls rather than one. - **Phase 02a Packet 6, step 1 — the propagation this ADR is not, on its own.** Until these land the corpus answers the question twice, and an implementer reading a standard rather than this ADR gets the withdrawn answer: @@ -264,6 +266,52 @@ configuration may call `ValueGeneratedOnAddOrUpdate()` or `IsRowVersion()` on a concurrency token. A structural test can see that; it cannot see a silently inert token. +### Amendment 2 — the three calls, in full (2026-08-28) + +Amendment 1 fixed which call is wrong and left "`IsConcurrencyToken()` and +nothing else" as the prescription. Applied literally against the canonical DDL +template it is incomplete, and the first configuration written to it shipped a +model that the packet's own registered rule rejects. + +Measured against `TenancyDbContext` — four `AuditableEntity` aggregates, EF Core +10 + Npgsql 10, model inspection only: + +| Configuration | `ValueGenerated` | `DEFAULT 0` in the DDL | +|---|---|---| +| `IsConcurrencyToken()` | `Never` | **absent** | +| `HasDefaultValue(0L).IsConcurrencyToken()` | **`OnAdd`** | present | +| `HasDefaultValue(0L).IsConcurrencyToken().ValueGeneratedNever()` | `Never` | present | + +`HasDefaultValue` sets `ValueGenerated = OnAdd` as a side effect, and +[Database Standards § Audit Columns](../standards/05-database.md)'s template +declares `row_version bigint NOT NULL DEFAULT 0` — a default a raw-SQL insert +that omits the column relies on. So the two requirements are only satisfiable +together by the third row. + +**The configuration is three calls:** + +```csharp +builder.Property(x => x.Version) + .HasColumnName("row_version") + .HasDefaultValue(0L) // the template's DEFAULT 0 + .IsConcurrencyToken() // the token + .ValueGeneratedNever(); // undo HasDefaultValue's OnAdd side effect +``` + +`OnAdd` is benign in isolation — the sentinel is `0`, `MarkCreated` does not +increment, so the insert matches and the `UPDATE` still carries the column. It is +rejected anyway: it is a *store-generated* declaration on a column the aggregate +increments, one keyword away from the `OnAddOrUpdate` that Amendment 1 measured +losing updates silently, and a reader cannot tell from the model which of the two +they are looking at. + +`Aggregates_With_Optimistic_Concurrency_Map_RowVersion` therefore asserts +`ValueGenerated == Never` alongside both save behaviours, and is **implemented** +as of Phase 02a Packet 6 (`PersistenceConventionTests`). The prescription in +§ Implementation Notes and in +[Database Standards § Concurrency](../standards/05-database.md) is corrected in +place; the **Decision is unchanged**. + ## References - [ADR-0002 — Initial Architecture](0002-initial-architecture.md) diff --git a/docs/glossary.md b/docs/glossary.md index 0ddb2943..c41a4600 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -117,8 +117,12 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | Term | Definition | |------|------------| -| **Tenant-owned table** | A database table that holds rows scoped to a single tenant. Has a `tenant_id` column and is protected by a global query filter and (later) RLS policy. | -| **Global table** | A database table that lives above tenants (e.g. `tenants`, `users`, `plans`). | +| **Tenant-owned table** | A database table whose every row belongs to one tenant, protected by an EF global query filter **and** a Row Level Security policy from the migration that creates it. Three sub-classes, per [Database Standards § Table classes](standards/05-database.md). | +| **Tenant-owned, tenant-wide** | The ordinary sub-class: a `tenant_id` column, one `AND`-ed policy keyed on it. | +| **Tenant-owned, organization-scoped** | Adds a nullable `organization_id`, the `app.scope` read hatch, and two `AS RESTRICTIVE` write guards. | +| **Tenant-owned, self-keyed** | `tenants` itself. It has **no** `tenant_id` column — its `id` *is* the tenant id, so its policy keys on `id`. | +| **Platform-scoped table** | A table read *before* a tenant is known, so the tenant-owned template would return zero rows forever. `platform_host_to_tenant` is the only one; its policies are qualified `TO learnstack_app` and admit the announced host. Adding a second is a decision, not a convenience. | +| **Global table** | A table with no tenant dimension at all — a person, not a person-in-a-tenant. `users` is the case ([Phase 03](roadmap/phase-03-identity-admin.md)); tenancy arrives through the membership row beside it. `tenants` is **not** one: it is tenant-owned, self-keyed. | | **Tenant context** | The ambient resolved tenant for a request, job, or background task. | | **Query filter** | EF Core global query filter that injects `WHERE tenant_id = @current_tenant_id` automatically. | diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index 25ffeac7..8f8dab4d 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -1,7 +1,8 @@ # Module Spec — Tenancy **Status:** Design stable, partially implemented (Phase 02a Packet 6 shipped the -schema; commands, resolution and the isolation suite are Packet 7). +schema and its schema-level isolation suite; commands, host resolution and the +request-level isolation suite are Packet 7). The first module spec in the repository, per [Documentation Standards § Per-Module Specifications](../../standards/13-documentation.md). @@ -258,6 +259,15 @@ request and are the only Tenancy work an anonymous visitor pays for. rows with `is_default = true` for one tenant. A partial unique index would fix it; whether the invariant belongs in the database or in the aggregate is Packet 7's call, with the first code that reads it. +- **Nothing stops a tenant claiming a hostname it does not own.** + `ux_tenant_domains_host` is globally unique — it has to be, or a host would + resolve to two tenants — so the *first* tenant to insert a `Requested` row for + `school.example.com` blocks every other tenant from claiming it, verified or + not. The index is partial on `deleted_at IS NULL`, so releasing a claim frees + the name; what has no owner yet is the policy that decides how long an + unverified claim may hold one. The custom-domain lifecycle is + [Phase 02c](../../roadmap/phase-02c-hub-foundation.md), and this is one of the + rules it has to write. - **`tenant_domains.host` and `platform_host_to_tenant.host` can disagree.** They are separate tables on purpose — one is read under tenant context, the other before any context exists — but nothing enforces that a verified domain has a diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index 7125ac38..ce66957e 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -490,6 +490,15 @@ suite includes at minimum: - `Unsetting_tenant_context_returns_zero_rows_through_RLS` - `Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck` +**All five shipped in Packet 6** (`TenancySchemaTests`), against the two-tenant +seed that packet's own coverage needed anyway: every case is a statement about +the migration, and holding them back would have left the schema's own assertions +tautological — the first version of the owner-denial case passed with every +policy dropped, because nothing had put a row in the table. What Packet 7 owns is +what a *request* does: the same five re-run through `TenantResolverMiddleware` +and the EF query filters rather than through `set_config` in a test, plus +whatever the two seed tenants add. + Carries two Packet 3 follow-ups: converting `ITenantContext` **and** `CapturedContext` from raw `Guid` / `Guid?` to the strongly-typed `TenantId` / `OrganizationId` value objects created in Packet 6, in a single pass to avoid a @@ -758,7 +767,7 @@ have to retrofit: transport swappable later ([ADR-0006](../decisions/0006-events-and-outbox.md)). -**Four of the ten have no published column list at all, and this packet authors +**Five of the ten have no published column list at all, and this packet authors them.** `outbox_messages` and `idempotency_keys` have canonical DDL ([Database Standards](../standards/05-database.md)); `tenant_feature_flags` and `platform_entitlement_cache` have `CREATE TABLE` blocks in @@ -766,7 +775,14 @@ them.** `outbox_messages` and `idempotency_keys` have canonical DDL resolution logic already reads by column name, so the migration **transcribes** those and adds the row-security clauses that architecture doc omits; `tenant_locales` has a sketch in -[12-localization.md](../architecture/12-localization.md). The columns of `tenants`, +[12-localization.md](../architecture/12-localization.md). Where a transcription and +the shipped migration disagreed, the disagreement was closed in one direction and +the architecture doc amended to the shipped shape — length caps on `key`, +`plan_code` and `locale`, which the fences left as bare `text` — while the +migration took the two `DEFAULT now()` clauses it had dropped, because a raw +insert that omits the column relies on them. `source` stayed `text`: it is a +closed set, and Database Standards § Column types fixes `text` + `CHECK` as the +form for those. The columns of `tenants`, `organizations`, `tenant_domains`, `tenant_settings` and `platform_host_to_tenant` are designed here, against the constraints the corpus does fix — the audit-column set, the table classes, the composite-foreign-key rule, ADR-0017's organization diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 0853ef84..4f35a600 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -401,8 +401,19 @@ Two consequences are binding: `tenants.slug` is globally unique, and PostgreSQL enforces unique indexes with row security bypassed, so a duplicate-slug insert reveals that *some* tenant already holds the slug. That is accepted here because slugs appear in hostnames and are public by -construction. It is not accepted anywhere else, which is why tenant-owned natural keys -are `UNIQUE (tenant_id, …)`. +construction. + +**Exactly two columns carry that cost.** The second is `tenant_domains.host`, and for +the same reason stated the other way round: a host resolving to two tenants is +unresolvable no matter who owns it, so global uniqueness is not a convenience but the +constraint the resolver depends on. Its index is **partial** — +`UNIQUE (host) WHERE deleted_at IS NULL` — because a table-wide unique would let a +soft-deleted claim hold a hostname forever, and +[ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) contemplates a +released-then-re-registered domain. + +Adding a third is a decision, not a convenience. Every other tenant-owned natural key +is `UNIQUE (tenant_id, …)`. #### `platform_host_to_tenant` — platform-scoped @@ -724,8 +735,22 @@ writing on a path that ADR deliberately keeps read-only. ## Concurrency `row_version bigint`, CLR `long`, on every entity implementing -`IOptimisticConcurrency`. Configure it with **`IsConcurrencyToken()` and nothing -else**. +`IOptimisticConcurrency`. Configure it with exactly these three calls +([ADR-0039 Amendment 2](../decisions/0039-optimistic-concurrency-token.md)): + +```csharp +builder.Property(x => x.Version) + .HasColumnName("row_version") + .HasDefaultValue(0L) // the DEFAULT 0 this template declares + .IsConcurrencyToken() // the token + .ValueGeneratedNever(); // undo HasDefaultValue's OnAdd side effect +``` + +`HasDefaultValue` sets `ValueGenerated = OnAdd` as a side effect, so +`IsConcurrencyToken()` on its own is only correct on a column with no default — +which is not the column this standard declares. +`Aggregates_With_Optimistic_Concurrency_Map_RowVersion` asserts +`ValueGenerated == Never`, so the two-call form fails it. Neither `.ValueGeneratedOnAddOrUpdate()` nor `IsRowVersion()` may be added, and they are the same mistake: on a `long` property they produce byte-identical diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 327dea93..0f7e7a20 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -621,8 +621,14 @@ otherwise). out of scope here. - **Source:** [ADR-0017 Amendment 2 (2026-08-10)](../decisions/0017-tenant-organization-hierarchy.md); [03-module-boundaries.md § Tenancy](../architecture/03-module-boundaries.md). -- **Type:** xUnit + NetArchTest. **Kind:** structural. -- **Status:** **Registered.** +- **Type:** xUnit + reflection over the enumerated module `Domain` assemblies. + **Kind:** structural. +- **Status:** **Implemented** (Packet 6 step 4, + `LearnStack.Tests.Architecture`, `TenancyConventionTests`). `Organization` + exists and is asserted; `OrganizationBranding` does not yet, and "exactly one, + in Tenancy" is satisfied by none — which is what stops the first one landing in + the wrong module. Closes in Packet 10 when the remaining module `Domain` + assemblies carry types. - **Phase:** 02a (Packet 6 introduces, Packet 10 closes). ### Persistence: concurrency and the unit of work @@ -644,9 +650,28 @@ rules that need a second `DbContext` are owed by Phase 03. measured). A structural test can see the metadata; it cannot see a silently inert token, which is why the assertion is on `ValueGenerated` and not on the call site. -- **Source:** ADR-0039; [05-database.md § Concurrency](05-database.md). +- **Source:** ADR-0039 (Amendments 1 and 2); + [05-database.md § Concurrency](05-database.md). - **Type:** xUnit + EF model inspection. **Kind:** structural. -- **Status:** **Registered.** **Phase:** 02a Packet 6. +- **Status:** **Implemented** (Packet 6 step 4, + `LearnStack.Tests.Architecture`, `PersistenceConventionTests`). + Mutation-checked: dropping `.ValueGeneratedNever()` from `MapAuditColumns` + fails this case and only this case. + +#### `Migration_Startup_Project_References_EntityFrameworkCore_Design` + +- **Asserts:** `backend/src/LearnStack.Api/LearnStack.Api.csproj` carries a + `PackageReference` to `Microsoft.EntityFrameworkCore.Design`. `dotnet ef` resolves + the design-time package from the **startup** project, and + [`make migrate`](../standards/05-database.md) names that one; without the reference + the tool refuses before it opens a connection. The failure is invisible to the test + suite, which calls `Database.MigrateAsync()` directly — Packet 6 shipped a migration + in exactly that state, green under Testcontainers and inapplicable by the only path + the corpus documents. +- **Source:** [05-database.md § Migrations](05-database.md); the `migrate` target. +- **Type:** xUnit + project-file inspection. **Kind:** structural. +- **Status:** **Implemented** (Packet 6 step 4, + `LearnStack.Tests.Architecture`, `PersistenceConventionTests`). #### `SoftDelete_Advances_The_Row_Version` @@ -786,8 +811,13 @@ first two rows are coverage checks; the last three are the proof. - **Source:** ADR-0003 Amendment 3 § Test requirement. - **Type:** **integration** test (Testcontainers + PostgreSQL), not an architecture test. **Kind:** runtime. -- **Status:** **Registered.** -- **Phase:** 02a (Packet 7). +- **Status:** **Implemented** (Packet 6 step 4, + `LearnStack.Tests.Integration`, `TenancySchemaTests`). It moved forward because + the schema's own assertions needed the two-tenant seed anyway: without rows for + both tenants, every count assertion in that class passed against dropped + policies. Packet 7 re-runs it through `TenantResolverMiddleware` and the EF + query filters rather than through `set_config`. +- **Phase:** 02a (Packet 6 ships the schema-level case; Packet 7 the request-level one). #### `Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck` @@ -799,14 +829,18 @@ first two rows are coverage checks; the last three are the proof. - **Source:** ADR-0003 Amendment 3 § Test requirement; [05-database.md](05-database.md). - **Type:** **integration** test (Testcontainers + PostgreSQL). **Kind:** runtime. -- **Status:** **Registered.** -- **Phase:** 02a (Packet 7). - -The Packet 7 suite additionally carries `Tenant_A_cannot_read_Tenant_B_data`, -`Org_X_cannot_read_Org_Y_within_TenantA`, and -`Unsetting_tenant_context_returns_zero_rows_through_RLS`. Those are ordinary integration -tests named in the phase document rather than catalogue-governed rules; they are listed -in [Phase 02a Packet 7](../roadmap/phase-02a-kernel-tenancy.md). +- **Status:** **Implemented** (Packet 6 step 4, + `LearnStack.Tests.Integration`, `TenancySchemaTests`) — as a `[Theory]` over + both halves, because `WITH CHECK` guards `INSERT` and `UPDATE` and a rule + covering one leaves the other open. +- **Phase:** 02a (Packet 6 ships the schema-level case; Packet 7 the request-level one). + +`Tenant_A_cannot_read_Tenant_B_data`, `Org_X_cannot_read_Org_Y_within_TenantA` and +`Unsetting_tenant_context_returns_zero_rows_through_RLS` are ordinary integration tests +named in the phase document rather than catalogue-governed rules; all three shipped +alongside the two rules above in Packet 6 step 4 and are listed in +[Phase 02a Packet 7](../roadmap/phase-02a-kernel-tenancy.md), which re-runs them through +the request path. #### `Db_Connection_String_Is_TransactionPooled` From f67cacf9bc76d1cb9048ef7b178cfa962ab23a4f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 13:34:35 +0300 Subject: [PATCH 16/35] fix(tenancy): close the Step 4 Sonnet and Step 5 Opus rounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-five confirmed findings across nine lenses, and one shape behind most of them: a structural sweep is only as wide as the schema it runs against. Step 4's sweeps enumerate the catalogue instead of a hand-written list — but they ran on a fixture that applied only the tenancy chain, so "every table" meant eight of the ten and the two tables no module owns were outside every one of them. Measured: a second permissive SELECT policy on `outbox_messages` passed the entire suite while letting any session with any tenant context read every tenant's pending events, and `GRANT UPDATE ON outbox_messages TO learnstack_app` let a handler mark every pending row processed — making each event permanently undeliverable — with every assertion still green. The two schema fixtures are now one shared collection fixture that applies both chains and seeds all ten tables for both tenants. That single change puts row security, the permissive-policy rule, snake_case and the grant matrix over the whole schema, and it retires the two-entry `[InlineData]` row-security check that was standing in for them — an inclusion list wearing a different hat. What the widened sweeps then found, and what else was missing: - `fk_organizations_reporting_parent` and `fk_platform_host_to_tenant_organization` had no supporting index. `Every_Foreign_Key_Has_A_Supporting_Index` is new and found both on its first run, which is the evidence it is not decorative. - The grant matrix asserted `learnstack_app` only — the one role RLS already bounds. It now covers all three non-owner grantees across both chains, which is the whole of the bound on the two `BYPASSRLS` roles. - `TheApplicationRoleCanOnlyEnqueue` issued a `DELETE` and never an `UPDATE`, the half its own name is about. - `idempotency_keys` had no isolation assertion at all: `USING (true)` passed. - Neither platform policy's `WITH CHECK` was constrained. `Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck` now covers both tables. - The two `AS RESTRICTIVE` guards on `tenant_settings` could be deleted with the suite green, because no test ever set `app.scope = 'tenant'` — and under any ordinary session the base policy's organization term refuses the sibling row first. With the hatch set and the delete guard dropped, `DELETE` removed another organization's row; with the write guard dropped, an `UPDATE` reassigned it into the caller's own organization. - The idempotency assertions pinned constraint names rather than bounds, so a cap of zero passed. The body cap now asserts both sides, and the state set and key length have cases at each boundary. - Nothing tied `state` to the four response columns, so a `completed` row could carry no status code and no body and the claim statement would still call it replayable. `ck_idempotency_keys_outcome` closes it; ADR-0037 Amendment 3 records that and the `claimed_at` the reclaim branch never refreshed. - `make migrate`'s coverage of the platform chain — added in the previous round — had no guard. `Migrate_Target_Covers_Every_Migration_Chain` scans for chains rather than listing them. - Both history-table names were literals in four places. They are constants on the design-time factories now, which are what `dotnet ef` actually uses, so the assertion is against the deployment path rather than against what the fixture wrote itself. Two domain defects, and the unit coverage the module never had. A `Subdomain` is documented and diagrammed as permanently `Verified`, and `MarkVerified` / `MarkVerificationFailed` carried no guard on `Kind` — the schema does not object either, because the kind and status CHECKs are independent. Three of the four aggregate factories validated their foreign key and not their own identifier. `TenancyAggregateTests` covers both, the host-normalization guard the previous round widened, and the verification lifecycle. Finally, `PlatformDbContext`'s XML claimed ADR-0040's multi-context property was testable a phase early. ADR-0040 § What Packet 6 can and cannot prove says the opposite, and aims the warning at exactly that reader. ADR: ADR-0037 (Amendment 3), ADR-0003, ADR-0040 Module: Tenancy Co-Authored-By: Claude Opus 5 (1M context) --- .../src/LearnStack.Api/LearnStack.Api.csproj | 3 +- ...1_create_platform_infrastructure_tables.cs | 14 +- .../Persistence/PlatformDbContext.cs | 19 +- .../Persistence/PlatformDbContextFactory.cs | 13 +- .../Organization.cs | 7 + .../TenantDomain.cs | 31 + .../TenantSetting.cs | 7 + .../Persistence/Configurations.cs | 12 +- ...28092437_create_tenancy_schema.Designer.cs | 4 +- .../20260828092437_create_tenancy_schema.cs | 4 +- .../TenancyDbContextModelSnapshot.cs | 4 +- .../Persistence/TenancyDbContextFactory.cs | 13 +- .../PersistenceConventionTests.cs | 68 ++ .../Database/MigrationRollbackTests.cs | 4 +- .../Database/PlatformSchemaTests.cs | 303 ++++----- .../Database/SchemaFixture.cs | 291 +++++++++ .../Database/SchemaQueries.cs | 125 ++++ .../Database/TenancySchemaTests.cs | 608 ++++++------------ .../Modules/Tenancy/TenancyAggregateTests.cs | 168 +++++ .../0037-idempotency-key-contract.md | 47 +- docs/modules/tenancy/README.md | 14 +- docs/standards/05-database.md | 13 +- .../21-architecture-tests-catalogue.md | 33 + 23 files changed, 1207 insertions(+), 598 deletions(-) create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/SchemaFixture.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/SchemaQueries.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs diff --git a/backend/src/LearnStack.Api/LearnStack.Api.csproj b/backend/src/LearnStack.Api/LearnStack.Api.csproj index a830dc6e..f5d81ee3 100644 --- a/backend/src/LearnStack.Api/LearnStack.Api.csproj +++ b/backend/src/LearnStack.Api/LearnStack.Api.csproj @@ -56,7 +56,8 @@ Without it the tool refuses before it opens a connection — "Your startup project 'LearnStack.Api' doesn't reference Microsoft.EntityFrameworkCore.Design" — which is how Packet 6 shipped a migration no documented path could apply. - `Api_References_EntityFrameworkCore_Design` is the guard. PrivateAssets is + `Migration_Startup_Project_References_EntityFrameworkCore_Design` + is the guard. PrivateAssets is still correct: nothing at runtime touches it. --> diff --git a/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20260828085701_create_platform_infrastructure_tables.cs b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20260828085701_create_platform_infrastructure_tables.cs index e4747e85..a41545a0 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20260828085701_create_platform_infrastructure_tables.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20260828085701_create_platform_infrastructure_tables.cs @@ -83,7 +83,19 @@ CONSTRAINT ck_idempotency_keys_body_size -- Matches [Idempotent]'s header bounds, so a key the API accepted -- always fits. CONSTRAINT ck_idempotency_keys_key_length - CHECK (length(key) BETWEEN 8 AND 128) + CHECK (length(key) BETWEEN 8 AND 128), + -- The state and the response columns are one fact, not two. + -- ADR-0037 Amendment 2's claim statement reports a `completed` + -- row as replayable, so a `completed` row with no status code + -- and no body makes the caller replay a response that does not + -- exist; and the reclaim branch NULLs all four alongside + -- `state = 'in_flight'`, so the reverse is equally a lie about + -- what the row is. content_type stays free in the completed + -- arm — the port defines it as null for an empty body. + CONSTRAINT ck_idempotency_keys_outcome CHECK ( + (state = 'completed' AND status_code IS NOT NULL AND body IS NOT NULL) + OR (state <> 'completed' AND status_code IS NULL AND content_type IS NULL + AND headers IS NULL AND body IS NULL)) ); -- Serves both the retention sweep and the per-tenant admission count, diff --git a/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContext.cs b/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContext.cs index b6c72584..0e6088d9 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContext.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContext.cs @@ -17,13 +17,15 @@ namespace LearnStack.Infrastructure.Persistence; /// module. /// /// -/// It is the second DbContext, and that is useful rather than -/// incidental. -/// ADR-0040 -/// says its central property — several contexts enlisted on one connection, so -/// SET LOCAL protects every statement — becomes testable only when a -/// second context exists, and expected that in Phase 03. It exists here, so the -/// property is testable a phase earlier. +/// It is the second DbContext, and that does not yet make +/// ADR-0040's +/// central property testable. That property is several contexts enlisted on one +/// connection so SET LOCAL protects every statement, and the enlistment +/// machinery — IUnitOfWork, the shared registration helper, the +/// TransactionBehavior body — lands in step 6; ADR-0040 § What Packet 6 +/// can and cannot prove says the property becomes observable in Phase 03, with +/// the second module context. This context exists for the reason below and +/// no other. /// /// /// Both tables are tenant-owned, tenant-wide despite living outside a @@ -47,8 +49,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // designs avoid. // // The context exists to own the MIGRATION for these tables — that is what - // needs a model root — and to be the second context ADR-0040's property - // requires. + // needs a model root. base.OnModelCreating(modelBuilder); } } diff --git a/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContextFactory.cs b/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContextFactory.cs index 9a7a0d42..c1d6c979 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContextFactory.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContextFactory.cs @@ -19,6 +19,17 @@ public sealed class PlatformDbContextFactory : IDesignTimeDbContextFactory + /// The migration history table for this chain — its own, so the two chains + /// advance independently. + /// + /// + /// Public for the same reason as Tenancy's: the fixtures reference it instead + /// of repeating the literal, so the assertion is against the name the + /// deployment path uses. + /// + public const string HistoryTable = "__ef_migrations_history_platform"; + public PlatformDbContext CreateDbContext(string[] args) { var connectionString = Environment.GetEnvironmentVariable(ConnectionStringVariable); @@ -36,7 +47,7 @@ public PlatformDbContext CreateDbContext(string[] args) // Its own history table, so the two chains advance independently: // a module's migration must not be blocked by, or block, the // platform's. - npgsql.MigrationsHistoryTable("__ef_migrations_history_platform")) + npgsql.MigrationsHistoryTable(HistoryTable)) .Options; return new PlatformDbContext(options); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs index a49d33cb..f1605099 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs @@ -106,6 +106,13 @@ public static Organization Create( ArgumentException.ThrowIfNullOrWhiteSpace(slug); ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + if (!id.IsInitialized()) + { + throw new ArgumentException( + "The identifier was never assigned; construct it through its factory.", + nameof(id)); + } + if (!tenantId.IsInitialized()) { throw new ArgumentException( diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs index 3587962f..3a775df9 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs @@ -89,6 +89,7 @@ public static TenantDomain RequestCustomDomain( public void MarkVerified(IClock clock, UserId updatedBy) { ArgumentNullException.ThrowIfNull(clock); + EnsureVerifiable(); Status = TenantDomainStatus.Verified; VerifiedAt = clock.UtcNow; @@ -102,6 +103,7 @@ public void MarkVerificationFailed(string error, IClock clock, UserId updatedBy) { ArgumentNullException.ThrowIfNull(clock); ArgumentException.ThrowIfNullOrWhiteSpace(error); + EnsureVerifiable(); Status = TenantDomainStatus.Failed; VerificationAttempts++; @@ -109,6 +111,28 @@ public void MarkVerificationFailed(string error, IClock clock, UserId updatedBy) MarkUpdated(clock.UtcNow, updatedBy); } + /// + /// Only a domain travels the + /// verification path. + /// + /// + /// A is verified by construction — + /// the platform controls the zone — and every state diagram in the corpus + /// draws it that way. Without this guard the two verification methods would + /// move one to Verifying or Failed, a state the module spec says + /// it cannot reach, and the schema would not object: the kind and status + /// CHECKs are independent single-column constraints. + /// + private void EnsureVerifiable() + { + if (Kind != TenantDomainKind.Custom) + { + throw new InvalidOperationException( + $"A {Kind} domain is verified by construction and has no verification lifecycle; " + + "only a Custom domain can be marked verified or failed."); + } + } + private static TenantDomain CreateCore( TenantDomainId id, TenantId tenantId, @@ -120,6 +144,13 @@ private static TenantDomain CreateCore( ArgumentNullException.ThrowIfNull(clock); ArgumentException.ThrowIfNullOrWhiteSpace(host); + if (!id.IsInitialized()) + { + throw new ArgumentException( + "The identifier was never assigned; construct it through its factory.", + nameof(id)); + } + if (!tenantId.IsInitialized()) { throw new ArgumentException("A domain belongs to a tenant.", nameof(tenantId)); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs index afad6b9f..f00c392d 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs @@ -69,6 +69,13 @@ public static TenantSetting Create( ArgumentException.ThrowIfNullOrWhiteSpace(key); ArgumentException.ThrowIfNullOrWhiteSpace(value); + if (!id.IsInitialized()) + { + throw new ArgumentException( + "The identifier was never assigned; construct it through its factory.", + nameof(id)); + } + if (!tenantId.IsInitialized()) { throw new ArgumentException("A setting belongs to a tenant.", nameof(tenantId)); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs index 5cec1f5c..85ded34a 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs @@ -342,8 +342,14 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.IsActive).IsRequired(); builder.Property(x => x.IsPubliclyLive).IsRequired(); - // A tenant listing its own hosts is the second of the two read paths the - // policy admits, so it gets an index; the first (by host) is the key. - builder.HasIndex(x => x.TenantId).HasDatabaseName("ix_platform_host_to_tenant_tenant_id"); + // Two jobs, one index. A tenant listing its own hosts is the second of the + // two read paths the policy admits (the first, by host, is the key), and + // tenant_id leads, so the composite serves it. The organization column is + // there because fk_platform_host_to_tenant_organization is composite on + // (tenant_id, organization_id) and Standards 05 § Indexes says index every + // foreign key — a leading-column-only index does not serve the ON DELETE + // RESTRICT scan, it just narrows it to the tenant. + builder.HasIndex(x => new { x.TenantId, x.OrganizationId }) + .HasDatabaseName("ix_platform_host_to_tenant_tenant_id_organization_id"); } } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.Designer.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.Designer.cs index 747d8e27..8343cf8f 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.Designer.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.Designer.cs @@ -192,8 +192,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasKey("Host") .HasName("pk_platform_host_to_tenant"); - b.HasIndex("TenantId") - .HasDatabaseName("ix_platform_host_to_tenant_tenant_id"); + b.HasIndex("TenantId", "OrganizationId") + .HasDatabaseName("ix_platform_host_to_tenant_tenant_id_organization_id"); b.ToTable("platform_host_to_tenant", (string)null); }); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.cs index 2109b04a..4a9ba817 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.cs @@ -187,9 +187,9 @@ protected override void Up(MigrationBuilder migrationBuilder) unique: true); migrationBuilder.CreateIndex( - name: "ix_platform_host_to_tenant_tenant_id", + name: "ix_platform_host_to_tenant_tenant_id_organization_id", table: "platform_host_to_tenant", - column: "tenant_id"); + columns: new[] { "tenant_id", "organization_id" }); migrationBuilder.CreateIndex( name: "ix_tenant_domains_tenant_id", diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs index d1e42743..d7d3ebda 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs @@ -189,8 +189,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Host") .HasName("pk_platform_host_to_tenant"); - b.HasIndex("TenantId") - .HasDatabaseName("ix_platform_host_to_tenant_tenant_id"); + b.HasIndex("TenantId", "OrganizationId") + .HasDatabaseName("ix_platform_host_to_tenant_tenant_id_organization_id"); b.ToTable("platform_host_to_tenant", (string)null); }); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs index 32991275..73c4af36 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs @@ -44,6 +44,17 @@ public sealed class TenancyDbContextFactory : IDesignTimeDbContextFactory + /// The migration history table for this chain. + /// + /// + /// Public and referenced by the test fixtures rather than repeated as a + /// literal beside them. `dotnet ef` — and therefore `make migrate` — only ever + /// goes through this factory, so a fixture that wrote its own copy would + /// assert the name it chose while the deployment path drifted underneath it. + /// + public const string HistoryTable = "__ef_migrations_history_tenancy"; + public TenancyDbContext CreateDbContext(string[] args) { var connectionString = Environment.GetEnvironmentVariable(ConnectionStringVariable); @@ -62,7 +73,7 @@ public TenancyDbContext CreateDbContext(string[] args) var options = new DbContextOptionsBuilder() .UseNpgsql(connectionString, npgsql => - npgsql.MigrationsHistoryTable("__ef_migrations_history_tenancy")) + npgsql.MigrationsHistoryTable(HistoryTable)) .Options; return new TenancyDbContext(options); diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index d72f0768..3b1586e9 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -103,6 +103,74 @@ public void Migration_Startup_Project_References_EntityFrameworkCore_Design() + "dotnet ef resolves the design-time package from there"); } + [Fact] + public void Migrate_Target_Covers_Every_Migration_Chain() + { + // `make migrate` is the only documented path that applies a migration, and + // its project loop is a glob. The first version globbed `src/Modules` only, + // which left the platform chain — outbox_messages and idempotency_keys — + // unapplied by every documented path while the Testcontainers fixtures, + // which call Database.MigrateAsync() directly, stayed green. + // + // Scanned rather than listed: the assertion is that every directory under + // backend/src carrying a Persistence/Migrations folder is reachable from + // the recipe, so adding a chain and forgetting the Makefile fails here + // rather than in a deployment. + var recipe = ReadMigrateRecipe(); + + var chains = Directory + .EnumerateDirectories(RepositoryPaths.BackendSrc(), "Migrations", SearchOption.AllDirectories) + .Where(path => Path.GetFileName(Path.GetDirectoryName(path)) == "Persistence") + .Select(path => Path.GetDirectoryName(Path.GetDirectoryName(path))!) + .Select(project => Path.GetRelativePath(RepositoryPaths.RepoRoot(), project) + .Replace(Path.DirectorySeparatorChar, '/')) + .ToList(); + + chains.Should().NotBeEmpty("the tenancy and platform chains both exist"); + + var uncovered = chains + .Where(chain => !RecipeReaches(recipe, chain)) + .ToList(); + + uncovered.Should().BeEmpty( + "`make migrate` applies every chain, or the ones it misses are " + + "unmigrated on the only path Standards 05 § Database roles documents"); + } + + /// + /// The body of the repo-root Makefile's migrate target. + /// + private static string ReadMigrateRecipe() + { + var lines = File.ReadAllLines(Path.Combine(RepositoryPaths.RepoRoot(), "Makefile")); + var start = Array.FindIndex(lines, line => line.StartsWith("migrate:", StringComparison.Ordinal)); + + start.Should().BeGreaterThanOrEqualTo(0, "the Makefile carries a `migrate` target"); + + var body = lines.Skip(start + 1).TakeWhile(line => line.StartsWith('\t')); + return string.Join('\n', body); + } + + /// + /// True when the recipe names the project directly or through a glob that + /// covers it. + /// + /// + /// A glob segment is matched by translating * to "anything but a + /// separator", which is what the shell does. Comparing the literal string + /// would fail on the module loop, which is a glob by design — one entry per + /// module would be the maintenance burden this rule exists to remove. + /// + private static bool RecipeReaches(string recipe, string projectPath) => + recipe + .Split([' ', '\n', '\t', ';'], StringSplitOptions.RemoveEmptyEntries) + .Where(token => token.StartsWith("backend/src", StringComparison.Ordinal)) + .Any(token => System.Text.RegularExpressions.Regex.IsMatch( + projectPath, + "^" + string.Join( + "[^/]*", + token.Split('*').Select(System.Text.RegularExpressions.Regex.Escape)) + "$")); + /// /// Builds the Tenancy model without a database. /// diff --git a/backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs index be4cafa0..68929f1e 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs @@ -114,12 +114,12 @@ public async Task RollBackAsync() private TenancyDbContext CreateTenancy() => new(new DbContextOptionsBuilder() .UseNpgsql(Postgres.MigrationConnectionString, npgsql => - npgsql.MigrationsHistoryTable("__ef_migrations_history_tenancy")) + npgsql.MigrationsHistoryTable(TenancyDbContextFactory.HistoryTable)) .Options); private PlatformDbContext CreatePlatform() => new(new DbContextOptionsBuilder() .UseNpgsql(Postgres.MigrationConnectionString, npgsql => - npgsql.MigrationsHistoryTable("__ef_migrations_history_platform")) + npgsql.MigrationsHistoryTable(PlatformDbContextFactory.HistoryTable)) .Options); } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PlatformSchemaTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PlatformSchemaTests.cs index ab37efe0..874055de 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PlatformSchemaTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PlatformSchemaTests.cs @@ -1,6 +1,6 @@ using FluentAssertions; using LearnStack.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; using Npgsql; using Xunit; @@ -8,37 +8,31 @@ namespace LearnStack.Tests.Integration.Database; /// /// outbox_messages and idempotency_keys — the two tables no module -/// owns. +/// owns — and the behaviour that is specific to them. /// /// -/// They ship in their own migration chain, on their own history table, so a -/// module's migration cannot block the platform's or be blocked by it. Both are -/// tenant-owned and tenant-wide despite living outside a module: every row -/// carries a tenant_id and the ordinary policy applies. +/// +/// The structural sweeps are not here: they live in +/// and, since both suites share +/// , they now enumerate a catalogue that includes these +/// two tables. That is the fix for the shape this class used to have — a +/// two-entry [InlineData] row-security check beside sweeps that could not +/// see either table, which let a second permissive policy on the outbox pass the +/// whole suite. +/// +/// +/// What is left here is what only these tables can be asked: the identifier +/// generator, the dispatcher's column-scoped grant, the enqueue-only bound on the +/// application role, and the idempotency constraints. +/// /// [Trait(RequiresDocker.Key, RequiresDocker.Value)] -public sealed class PlatformSchemaTests : IClassFixture +[Collection(SharedSchema.Name)] +public sealed class PlatformSchemaTests { - private static readonly Guid TenantA = Guid.Parse("11111111-1111-7111-8111-111111111111"); - private static readonly Guid TenantB = Guid.Parse("22222222-2222-7222-8222-222222222222"); + private readonly SchemaFixture _schema; - private readonly PlatformSchemaFixture _schema; - - public PlatformSchemaTests(PlatformSchemaFixture schema) => _schema = schema; - - [Theory] - [InlineData("outbox_messages")] - [InlineData("idempotency_keys")] - public async Task RowSecurityIsEnabledAndForced(string table) - { - await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); - await using var command = new NpgsqlCommand( - "SELECT relrowsecurity AND relforcerowsecurity FROM pg_class WHERE relname = @table", - (NpgsqlConnection)connection); - command.Parameters.AddWithValue("table", table); - - (await command.ExecuteScalarAsync()).Should().Be(true); - } + public PlatformSchemaTests(SchemaFixture schema) => _schema = schema; [Fact] public async Task TheOutboxIdDefaultsToAVersion7Uuid() @@ -51,7 +45,7 @@ public async Task TheOutboxIdDefaultsToAVersion7Uuid() // of the index locality ADR-0023 adopted v7 for. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); await using var transaction = await connection.BeginTransactionAsync(); - await SetTenantAsync(connection, transaction, TenantA); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); await using var insert = new NpgsqlCommand( """ @@ -59,48 +53,24 @@ INSERT INTO outbox_messages (tenant_id, correlation_id, type, topic, partition_k VALUES (@tenant, '00-trace-span-01', 'T', 'learnstack.tenancy.tenant', 'k', '{}') RETURNING uuid_extract_version(id) """, (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); - insert.Parameters.AddWithValue("tenant", TenantA); + insert.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); (await insert.ExecuteScalarAsync()).Should().Be((short)7); } - [Fact] - public async Task AnotherTenantCannotSeeOutboxRows() - { - await using var seed = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); - await using (var seedTx = await seed.BeginTransactionAsync()) - { - await SetTenantAsync(seed, seedTx, TenantA); - await using var insert = new NpgsqlCommand( - """ - INSERT INTO outbox_messages (tenant_id, correlation_id, type, topic, partition_key, payload) - VALUES (@tenant, '00-trace-span-02', 'T', 'learnstack.tenancy.tenant', 'k', '{}') - """, (NpgsqlConnection)seed, (NpgsqlTransaction)seedTx); - insert.Parameters.AddWithValue("tenant", TenantA); - await insert.ExecuteNonQueryAsync(); - await seedTx.CommitAsync(); - } - - await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); - await using var transaction = await connection.BeginTransactionAsync(); - await SetTenantAsync(connection, transaction, TenantB); - - await using var read = new NpgsqlCommand( - "SELECT count(*) FROM outbox_messages", - (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); - - (await read.ExecuteScalarAsync()).Should().Be(0L); - } - - [Fact] - public async Task TheApplicationRoleCanOnlyEnqueue() + [Theory] + [InlineData("UPDATE outbox_messages SET processed_at = now()")] + [InlineData("DELETE FROM outbox_messages")] + public async Task TheApplicationRoleCanOnlyEnqueue(string statement) { // No UPDATE and no DELETE on the outbox: status transitions belong to the - // dispatcher and purging to the audited platform scope. A handler that - // could mark a row processed could make an event vanish. + // dispatcher and purging to the audited platform scope. Both halves are + // asserted, because the UPDATE is the one that matters most and was the one + // missing — measured, granting learnstack_app table-wide UPDATE let a + // handler run `SET processed_at = now()` over every pending row, making + // each event permanently undeliverable, while the whole suite stayed green. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); - await using var command = new NpgsqlCommand( - "DELETE FROM outbox_messages", (NpgsqlConnection)connection); + await using var command = new NpgsqlCommand(statement, (NpgsqlConnection)connection); var act = async () => await command.ExecuteNonQueryAsync(); @@ -132,114 +102,151 @@ FROM information_schema.column_privileges } [Fact] - public async Task TheIdempotencyBodyCapIsEnforcedByTheDatabase() + public async Task TheDispatcherReadsEveryTenantWithNoTenantContext() { - await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); - await using var transaction = await connection.BeginTransactionAsync(); - await SetTenantAsync(connection, transaction, TenantA); - + // The one thing BYPASSRLS is for here. The dispatcher polls without a + // tenant — it does not know whose event is next — so a policy applied to + // it would return zero rows forever and the outbox would never drain. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.OutboxConnectionString); await using var command = new NpgsqlCommand( - """ - INSERT INTO idempotency_keys - (tenant_id, key, fingerprint, claim_token, state, expires_at, body) - VALUES (@tenant, 'oversized', 'fp', gen_random_uuid(), 'completed', now(), - repeat('x', 262145)::bytea) - """, (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); - command.Parameters.AddWithValue("tenant", TenantA); - - var act = async () => await command.ExecuteNonQueryAsync(); - - (await act.Should().ThrowAsync()) - .Which.ConstraintName.Should().Be("ck_idempotency_keys_body_size"); - } - - [Fact] - public async Task TheTwoMigrationChainsAdvanceIndependently() - { - // Separate history tables, so a module's migration cannot block the - // platform's or be blocked by it — and, from step 6, so ADR-0040's - // multi-context property has two contexts to be true of. - await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); - await using var command = new NpgsqlCommand( - """ - SELECT string_agg(tablename, ',' ORDER BY tablename) - FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE '\_\_ef%' - """, (NpgsqlConnection)connection); + "SELECT count(DISTINCT tenant_id) FROM outbox_messages", (NpgsqlConnection)connection); - (await command.ExecuteScalarAsync()).Should() - .Be("__ef_migrations_history_platform,__ef_migrations_history_tenancy"); + (await command.ExecuteScalarAsync()).Should().Be(2L, + "the fixture seeds one row per tenant and the dispatcher sees both"); } - private static async Task SetTenantAsync( - System.Data.Common.DbConnection connection, - System.Data.Common.DbTransaction transaction, - Guid tenantId) + [Theory] + // The accepting half as well as the rejecting one, so the assertion pins the + // BOUND rather than the constraint's name: with only the rejecting case, a cap + // of zero passed. 262144 is ADR-0037's 256 KiB replay cap. + [InlineData("repeat('x', 262144)::bytea", null)] + [InlineData("repeat('x', 262145)::bytea", "ck_idempotency_keys_body_size")] + public async Task TheIdempotencyBodyCapIsEnforcedByTheDatabase(string body, string? violated) { - await using var command = new NpgsqlCommand( - "SELECT set_config('app.tenant_id', @value, true)", - (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); - command.Parameters.AddWithValue("value", tenantId.ToString()); - await command.ExecuteNonQueryAsync(); + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + + var act = async () => await SchemaQueries.ExecuteAsync(connection, transaction, + $""" + INSERT INTO idempotency_keys + (tenant_id, key, fingerprint, claim_token, state, expires_at, + status_code, body) + VALUES (@tenant, 'body-cap-probe', 'fp', uuidv7(), 'completed', + now() + interval '1 day', 200, {body}) + """, + ("tenant", SchemaFixture.TenantA)); + + if (violated is null) + { + await act.Should().NotThrowAsync(); + } + else + { + (await act.Should().ThrowAsync()) + .Which.ConstraintName.Should().Be(violated); + } } -} - -/// -/// Applies both migration chains and seeds the two tenants the isolation cases -/// compare — the platform tables carry a tenant_id, so a tenant must exist -/// for a row to reference even though no foreign key enforces it. -/// -public sealed class PlatformSchemaFixture : IAsyncLifetime -{ - public PostgresFixture Postgres { get; } = new(); - public async Task InitializeAsync() + [Theory] + // The closed set Standards 05 § Column types names as its worked example, and + // the key bound the migration claims matches [Idempotent]'s header bounds + // (MinKeyLength 8, MaxKeyLength 128). Both were unasserted; each value below + // is one side of a boundary the API already enforces. + [InlineData("'in_flight'", "'valid-key'", null)] + [InlineData("'bogus_state'", "'valid-key'", "ck_idempotency_keys_state")] + [InlineData("'in_flight'", "'sevench'", "ck_idempotency_keys_key_length")] + [InlineData("'in_flight'", "repeat('k', 129)", "ck_idempotency_keys_key_length")] + [InlineData("'in_flight'", "repeat('k', 128)", null)] + public async Task TheIdempotencyClosedSetsAreEnforcedByTheDatabase( + string state, string key, string? violated) { - await Postgres.InitializeAsync(); + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); - var tenancy = new DbContextOptionsBuilder() - .UseNpgsql(Postgres.MigrationConnectionString, npgsql => - npgsql.MigrationsHistoryTable("__ef_migrations_history_tenancy")) - .Options; + var act = async () => await SchemaQueries.ExecuteAsync(connection, transaction, + $""" + INSERT INTO idempotency_keys + (tenant_id, key, fingerprint, claim_token, state, expires_at) + VALUES (@tenant, {key}, 'fp', uuidv7(), {state}, now() + interval '5 minutes') + """, + ("tenant", SchemaFixture.TenantA)); - await using (var context = new Modules.Tenancy.Infrastructure.Persistence.TenancyDbContext(tenancy)) + if (violated is null) { - await context.Database.MigrateAsync(); + await act.Should().NotThrowAsync(); } - - var platform = new DbContextOptionsBuilder() - .UseNpgsql(Postgres.MigrationConnectionString, npgsql => - npgsql.MigrationsHistoryTable("__ef_migrations_history_platform")) - .Options; - - await using (var context = new PlatformDbContext(platform)) + else { - await context.Database.MigrateAsync(); + (await act.Should().ThrowAsync()) + .Which.ConstraintName.Should().Be(violated); } - - await SeedTenantsAsync(); } - public async Task DisposeAsync() => await Postgres.DisposeAsync(); + [Theory] + // ck_idempotency_keys_outcome, which ties `state` to the four response + // columns. Without it a `completed` row could carry no status code and no + // body, and ADR-0037's claim statement would report it as replayable — the + // caller then replays a response that does not exist. + [InlineData("'completed'", "200", "'x'::bytea", null)] + [InlineData("'completed'", "NULL", "NULL", "ck_idempotency_keys_outcome")] + [InlineData("'in_flight'", "NULL", "NULL", null)] + [InlineData("'in_flight'", "201", "'x'::bytea", "ck_idempotency_keys_outcome")] + public async Task TheIdempotencyOutcomeShapeMatchesItsState( + string state, string statusCode, string body, string? violated) + { + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + + var act = async () => await SchemaQueries.ExecuteAsync(connection, transaction, + $""" + INSERT INTO idempotency_keys + (tenant_id, key, fingerprint, claim_token, state, expires_at, + status_code, body) + VALUES (@tenant, 'outcome-probe', 'fp', uuidv7(), {state}, + now() + interval '1 day', {statusCode}, {body}) + """, + ("tenant", SchemaFixture.TenantA)); + + if (violated is null) + { + await act.Should().NotThrowAsync(); + } + else + { + (await act.Should().ThrowAsync()) + .Which.ConstraintName.Should().Be(violated); + } + } - private async Task SeedTenantsAsync() + [Fact] + public async Task EachChainHasItsOwnHistoryTable() { - await using var connection = await PostgresFixture.OpenAsync(Postgres.MigrationConnectionString); + // Separate history tables, so a module's migration cannot block the + // platform's or be blocked by it. Compared against the constants the + // DESIGN-TIME FACTORIES declare, because those are the only objects + // `dotnet ef` — and therefore `make migrate` — ever uses: an earlier + // version compared against literals the fixture had written itself, so the + // deployment path could have drifted underneath a green assertion. + // + // That the chains actually advance independently is measured in + // MigrationRollbackTests, which reverses them in the opposite order to the + // one that applied them. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); await using var command = new NpgsqlCommand( """ - BEGIN; - SET LOCAL app.tenant_id = '11111111-1111-7111-8111-111111111111'; - INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) - VALUES ('11111111-1111-7111-8111-111111111111','alpha','Alpha','Trial', now(), - '00000000-0000-7000-8000-000000000001', 0); - COMMIT; - BEGIN; - SET LOCAL app.tenant_id = '22222222-2222-7222-8222-222222222222'; - INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) - VALUES ('22222222-2222-7222-8222-222222222222','beta','Beta','Trial', now(), - '00000000-0000-7000-8000-000000000001', 0); - COMMIT; + SELECT string_agg(tablename, ',' ORDER BY tablename) + FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE '\_\_ef%' """, (NpgsqlConnection)connection); - await command.ExecuteNonQueryAsync(); + var expected = string.Join(',', new[] + { + PlatformDbContextFactory.HistoryTable, + TenancyDbContextFactory.HistoryTable, + }.Order(StringComparer.Ordinal)); + + (await command.ExecuteScalarAsync()).Should().Be(expected); } } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/SchemaFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/SchemaFixture.cs new file mode 100644 index 00000000..68ad6b66 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/SchemaFixture.cs @@ -0,0 +1,291 @@ +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// Applies both migration chains and seeds every table they create, for +/// two tenants. +/// +/// +/// +/// Both chains, one fixture, and that is the point. The structural sweeps +/// — row security, the permissive-policy rule, snake_case, the grant matrix, +/// foreign-key indexing — enumerate the catalogue rather than a list of names, so +/// a fixture carrying only the tenancy chain silently narrows every one of them +/// to eight of the ten tables. That is the inclusion-list failure with a +/// different shape: measured, a second permissive SELECT policy on +/// outbox_messages passed the entire suite while letting any session with +/// any tenant context read every tenant's pending events. +/// +/// +/// Every table carries rows for both tenants. A count assertion against an +/// empty table passes whether or not the policy that should have emptied it +/// exists. Tenant A carries a second organization as well, so the organization +/// half of the template has a sibling row to hide. +/// +/// +/// Most of the seed runs as learnstack_migration inside a transaction that +/// sets app.tenant_id — the only way to insert, since every table's +/// WITH CHECK is live from the moment the migration finishes. +/// platform_host_to_tenant is the exception: its policies are qualified +/// TO learnstack_app, so the owner is denied on it and its rows go in +/// through the application role. +/// +/// +public sealed class SchemaFixture : IAsyncLifetime +{ + public static readonly Guid TenantA = Guid.Parse("11111111-1111-7111-8111-111111111111"); + public static readonly Guid TenantB = Guid.Parse("22222222-2222-7222-8222-222222222222"); + public static readonly Guid OrgA1 = Guid.Parse("aaaaaaaa-1111-7111-8111-111111111111"); + public static readonly Guid OrgA2 = Guid.Parse("aaaaaaaa-2222-7222-8222-222222222222"); + public static readonly Guid OrgB1 = Guid.Parse("bbbbbbbb-1111-7111-8111-111111111111"); + public static readonly Guid Actor = Guid.Parse("00000000-0000-7000-8000-000000000001"); + + public const string HostA = "alpha.example.com"; + public const string HostB = "beta.example.com"; + + /// + /// The ten tables the two chains create, used only to prove that a catalogue + /// sweep read something. + /// + /// + /// Not an inclusion list: no query filters on it. It exists so a sweep that + /// silently matched nothing fails instead of passing, which is the other way + /// a structural assertion can prove nothing. + /// + public static readonly string[] KnownTables = + [ + "tenants", "organizations", "tenant_domains", "tenant_locales", + "tenant_settings", "tenant_feature_flags", + "platform_entitlement_cache", "platform_host_to_tenant", + "outbox_messages", "idempotency_keys", + ]; + + /// What tenant A sees with its tenant context set and no organization scope. + public static readonly Dictionary RowsVisibleToTenantA = new(StringComparer.Ordinal) + { + ["tenants"] = 1, + ["organizations"] = 2, + ["tenant_domains"] = 1, + ["tenant_locales"] = 1, + // Three rows exist; two are organization-scoped and invisible without + // app.organization_id. Org_X_cannot_read_Org_Y_within_TenantA reads those. + ["tenant_settings"] = 1, + ["tenant_feature_flags"] = 1, + ["platform_entitlement_cache"] = 1, + ["platform_host_to_tenant"] = 1, + ["outbox_messages"] = 1, + ["idempotency_keys"] = 1, + }; + + /// What tenant B sees with its tenant context set. + public static readonly Dictionary RowsVisibleToTenantB = new(StringComparer.Ordinal) + { + ["tenants"] = 1, + ["organizations"] = 1, + ["tenant_domains"] = 1, + ["tenant_locales"] = 1, + ["tenant_settings"] = 1, + ["tenant_feature_flags"] = 1, + ["platform_entitlement_cache"] = 1, + ["platform_host_to_tenant"] = 1, + ["outbox_messages"] = 1, + ["idempotency_keys"] = 1, + }; + + public PostgresFixture Postgres { get; } = new(); + + public async Task InitializeAsync() + { + await Postgres.InitializeAsync(); + + // The history table names come from the design-time factories, which are + // what `dotnet ef` — and therefore `make migrate` — actually use. A + // fixture that repeated the literal would assert the name it wrote itself, + // and the deployment path could drift underneath a green suite. + await using (var tenancy = new TenancyDbContext( + new DbContextOptionsBuilder() + .UseNpgsql(Postgres.MigrationConnectionString, npgsql => + npgsql.MigrationsHistoryTable(TenancyDbContextFactory.HistoryTable)) + .Options)) + { + await tenancy.Database.MigrateAsync(); + } + + await using (var platform = new PlatformDbContext( + new DbContextOptionsBuilder() + .UseNpgsql(Postgres.MigrationConnectionString, npgsql => + npgsql.MigrationsHistoryTable(PlatformDbContextFactory.HistoryTable)) + .Options)) + { + await platform.Database.MigrateAsync(); + } + + await SeedAsync(); + } + + public async Task DisposeAsync() => await Postgres.DisposeAsync(); + + private async Task SeedAsync() + { + await using (var owner = await PostgresFixture.OpenAsync(Postgres.MigrationConnectionString)) + { + await using var command = new NpgsqlCommand(TenantRowsSql, (NpgsqlConnection)owner); + await command.ExecuteNonQueryAsync(); + } + + // platform_host_to_tenant only: its four policies are role-qualified TO + // learnstack_app, so under FORCE the owner is denied on it. + await using var app = await PostgresFixture.OpenAsync(Postgres.AppConnectionString); + await using var mappings = new NpgsqlCommand(HostMappingsSql, (NpgsqlConnection)app); + await mappings.ExecuteNonQueryAsync(); + } + + private const string TenantRowsSql = + """ + BEGIN; + SET LOCAL app.tenant_id = '11111111-1111-7111-8111-111111111111'; + + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('11111111-1111-7111-8111-111111111111','alpha','Alpha','Trial', now(), + '00000000-0000-7000-8000-000000000001', 0); + + INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('aaaaaaaa-1111-7111-8111-111111111111','11111111-1111-7111-8111-111111111111', + 'main','Main','Active', now(), '00000000-0000-7000-8000-000000000001', 0), + ('aaaaaaaa-2222-7222-8222-222222222222','11111111-1111-7111-8111-111111111111', + 'branch','Branch','Active', now(), '00000000-0000-7000-8000-000000000001', 0); + + UPDATE tenants SET default_organization_id = 'aaaaaaaa-1111-7111-8111-111111111111' + WHERE id = '11111111-1111-7111-8111-111111111111'; + + INSERT INTO tenant_domains + (id, tenant_id, host, kind, status, verification_attempts, created_at, created_by, row_version) + VALUES (uuidv7(),'11111111-1111-7111-8111-111111111111','alpha.example.com','Subdomain','Verified', + 0, now(), '00000000-0000-7000-8000-000000000001', 0); + + INSERT INTO tenant_locales (tenant_id, locale, is_default, is_enabled, sort) + VALUES ('11111111-1111-7111-8111-111111111111','tr-TR', true, true, 0); + + INSERT INTO tenant_settings (id, tenant_id, organization_id, key, value, created_at, created_by, row_version) + VALUES (uuidv7(),'11111111-1111-7111-8111-111111111111', NULL, + 'tz', '"Europe/Istanbul"', now(), '00000000-0000-7000-8000-000000000001', 0); + + -- One organization at a time, because the org-scoped WITH CHECK admits a + -- row only under its own organization's context. Writing both in one + -- statement is exactly what the guard refuses, and the seed is the first + -- place that shows it. + SET LOCAL app.organization_id = 'aaaaaaaa-1111-7111-8111-111111111111'; + INSERT INTO tenant_settings (id, tenant_id, organization_id, key, value, created_at, created_by, row_version) + VALUES (uuidv7(),'11111111-1111-7111-8111-111111111111','aaaaaaaa-1111-7111-8111-111111111111', + 'theme', '"main"', now(), '00000000-0000-7000-8000-000000000001', 0); + + SET LOCAL app.organization_id = 'aaaaaaaa-2222-7222-8222-222222222222'; + INSERT INTO tenant_settings (id, tenant_id, organization_id, key, value, created_at, created_by, row_version) + VALUES (uuidv7(),'11111111-1111-7111-8111-111111111111','aaaaaaaa-2222-7222-8222-222222222222', + 'theme', '"branch"', now(), '00000000-0000-7000-8000-000000000001', 0); + + INSERT INTO tenant_feature_flags (tenant_id, key, value, updated_by) + VALUES ('11111111-1111-7111-8111-111111111111','live-classroom','true', + '00000000-0000-7000-8000-000000000001'); + + INSERT INTO platform_entitlement_cache + (tenant_id, plan_code, features, limits, compliance, valid_until, source) + VALUES ('11111111-1111-7111-8111-111111111111','pro','{}','{}','{}', + now() + interval '30 days','null-provider'); + + INSERT INTO outbox_messages + (tenant_id, correlation_id, type, topic, partition_key, payload) + VALUES ('11111111-1111-7111-8111-111111111111','00-alpha-span-01','TenantCreated', + 'learnstack.tenancy.tenant','11111111-1111-7111-8111-111111111111','{}'); + + INSERT INTO idempotency_keys (tenant_id, key, fingerprint, claim_token, state, expires_at) + VALUES ('11111111-1111-7111-8111-111111111111','alpha-seed-key','fp-alpha', uuidv7(), + 'in_flight', now() + interval '5 minutes'); + COMMIT; + + BEGIN; + SET LOCAL app.tenant_id = '22222222-2222-7222-8222-222222222222'; + + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('22222222-2222-7222-8222-222222222222','beta','Beta','Trial', now(), + '00000000-0000-7000-8000-000000000001', 0); + + INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) + VALUES ('bbbbbbbb-1111-7111-8111-111111111111','22222222-2222-7222-8222-222222222222', + 'main','Main','Active', now(), '00000000-0000-7000-8000-000000000001', 0); + + UPDATE tenants SET default_organization_id = 'bbbbbbbb-1111-7111-8111-111111111111' + WHERE id = '22222222-2222-7222-8222-222222222222'; + + INSERT INTO tenant_domains + (id, tenant_id, host, kind, status, verification_attempts, created_at, created_by, row_version) + VALUES (uuidv7(),'22222222-2222-7222-8222-222222222222','beta.example.com','Subdomain','Verified', + 0, now(), '00000000-0000-7000-8000-000000000001', 0); + + INSERT INTO tenant_locales (tenant_id, locale, is_default, is_enabled, sort) + VALUES ('22222222-2222-7222-8222-222222222222','en-US', true, true, 0); + + INSERT INTO tenant_settings (id, tenant_id, organization_id, key, value, created_at, created_by, row_version) + VALUES (uuidv7(),'22222222-2222-7222-8222-222222222222', NULL, + 'beta-only', '"visible to beta alone"', now(), + '00000000-0000-7000-8000-000000000001', 0); + + INSERT INTO tenant_feature_flags (tenant_id, key, value, updated_by) + VALUES ('22222222-2222-7222-8222-222222222222','live-classroom','false', + '00000000-0000-7000-8000-000000000001'); + + INSERT INTO platform_entitlement_cache + (tenant_id, plan_code, features, limits, compliance, valid_until, source) + VALUES ('22222222-2222-7222-8222-222222222222','free','{}','{}','{}', + now() + interval '30 days','null-provider'); + + INSERT INTO outbox_messages + (tenant_id, correlation_id, type, topic, partition_key, payload) + VALUES ('22222222-2222-7222-8222-222222222222','00-beta-span-01','TenantCreated', + 'learnstack.tenancy.tenant','22222222-2222-7222-8222-222222222222','{}'); + + INSERT INTO idempotency_keys (tenant_id, key, fingerprint, claim_token, state, expires_at) + VALUES ('22222222-2222-7222-8222-222222222222','beta-seed-key','fp-beta', uuidv7(), + 'in_flight', now() + interval '5 minutes'); + COMMIT; + """; + + private const string HostMappingsSql = + """ + BEGIN; + SET LOCAL app.tenant_id = '11111111-1111-7111-8111-111111111111'; + INSERT INTO platform_host_to_tenant (host, tenant_id, organization_id, is_active, is_publicly_live) + VALUES ('alpha.example.com','11111111-1111-7111-8111-111111111111', + 'aaaaaaaa-1111-7111-8111-111111111111', true, true); + COMMIT; + + BEGIN; + SET LOCAL app.tenant_id = '22222222-2222-7222-8222-222222222222'; + INSERT INTO platform_host_to_tenant (host, tenant_id, organization_id, is_active, is_publicly_live) + VALUES ('beta.example.com','22222222-2222-7222-8222-222222222222', + 'bbbbbbbb-1111-7111-8111-111111111111', true, true); + COMMIT; + """; +} + +/// +/// The collection that shares one — and therefore one +/// container and one applied schema — between the tenancy and platform cases. +/// +/// +/// A shared collection rather than two IClassFixtures, because the point +/// of merging them is that the structural sweeps must see all ten tables. +/// Two class fixtures would be two containers with two half-schemas, which is the +/// arrangement that let a second permissive policy on outbox_messages pass +/// the whole suite. +/// +[CollectionDefinition(Name)] +public sealed class SharedSchema : ICollectionFixture +{ + public const string Name = "schema"; +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/SchemaQueries.cs b/backend/tests/LearnStack.Tests.Integration/Database/SchemaQueries.cs new file mode 100644 index 00000000..bf2cd7fa --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/SchemaQueries.cs @@ -0,0 +1,125 @@ +using System.Data.Common; +using Npgsql; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// The catalogue sweeps and session-context helpers the schema cases share. +/// +/// +/// Shared because both suites run against the same applied schema and must ask +/// the same question of it. A second copy of the catalogue query is a second +/// place for a table to go missing from, which is the failure these queries exist +/// to prevent. +/// +internal static class SchemaQueries +{ + /// + /// Every ordinary table in schema public, minus EF's history tables. + /// + /// + /// The only names written down anywhere in these sweeps, and they are written + /// down because they are the tables that are not part of the schema + /// under test: each carries MigrationId and ProductVersion, both + /// PascalCase, and no row security by design. + /// + public const string TableOids = + """ + SELECT oid FROM pg_class + WHERE relnamespace = 'public'::regnamespace AND relkind = 'r' + AND relname NOT LIKE '\_\_ef%' + """; + + public const string TableNames = + """ + SELECT relname FROM pg_class + WHERE relnamespace = 'public'::regnamespace AND relkind = 'r' + AND relname NOT LIKE '\_\_ef%' + ORDER BY relname + """; + + public static async Task> CountEveryTableAsync( + DbConnection connection, + DbTransaction? transaction) + { + var counts = new Dictionary(StringComparer.Ordinal); + + foreach (var table in await ReadStringsAsync(connection, TableNames, transaction)) + { + // The table name is a pg_class.relname read from the same connection + // moments earlier, not caller input; there is no bind parameter for an + // identifier, and the quoting is what makes the interpolation safe. + await using var command = new NpgsqlCommand( + $"SELECT count(*) FROM {Quote(table)}", + (NpgsqlConnection)connection, (NpgsqlTransaction?)transaction); + + counts[table] = (long)(await command.ExecuteScalarAsync())!; + } + + return counts; + } + + public static async Task> ReadStringsAsync( + DbConnection connection, + string sql, + DbTransaction? transaction = null) + { + await using var command = new NpgsqlCommand( + sql, (NpgsqlConnection)connection, (NpgsqlTransaction?)transaction); + + var values = new List(); + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + values.Add(reader.GetString(0)); + } + + return values; + } + + public static async Task ExecuteAsync( + DbConnection connection, + DbTransaction? transaction, + string sql, + params (string Name, object Value)[] parameters) + { + await using var command = new NpgsqlCommand( + sql, (NpgsqlConnection)connection, (NpgsqlTransaction?)transaction); + + foreach (var (name, value) in parameters) + { + command.Parameters.AddWithValue(name, value); + } + + await command.ExecuteNonQueryAsync(); + } + + public static Task SetTenantAsync( + DbConnection connection, + DbTransaction transaction, + Guid tenantId) + => SetSettingAsync(connection, transaction, "app.tenant_id", tenantId.ToString()); + + public static async Task SetSettingAsync( + DbConnection connection, + DbTransaction transaction, + string name, + string value) + { + // set_config(..., true) rather than SET LOCAL: PostgreSQL's SET takes no + // bind parameter — `SET LOCAL x = $1` is a syntax error, measured — and the + // third argument `true` is what makes it transaction-local. The transaction + // is passed explicitly because that locality is the whole point: a setting + // applied to the wrong transaction is applied to nothing. + await using var command = new NpgsqlCommand( + "SELECT set_config(@name, @value, true)", + (NpgsqlConnection)connection, + (NpgsqlTransaction)transaction); + command.Parameters.AddWithValue("name", name); + command.Parameters.AddWithValue("value", value); + await command.ExecuteNonQueryAsync(); + } + + private static string Quote(string identifier) => + "\"" + identifier.Replace("\"", "\"\"", StringComparison.Ordinal) + "\""; +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs index 6b9e79e1..1b2604e3 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs @@ -1,14 +1,12 @@ using FluentAssertions; -using LearnStack.Modules.Tenancy.Infrastructure.Persistence; -using Microsoft.EntityFrameworkCore; using Npgsql; using Xunit; namespace LearnStack.Tests.Integration.Database; /// -/// The tenancy schema, its Row Level Security policies and its grants, asserted -/// against a real PostgreSQL with the four roles provisioned. +/// The applied schema — both chains — its Row Level Security policies and its +/// grants, asserted against a real PostgreSQL with the four roles provisioned. /// /// /// @@ -16,19 +14,18 @@ namespace LearnStack.Tests.Integration.Database; /// as the owner — or as either BYPASSRLS role — would pass with every policy /// inert and prove nothing, which is the failure mode /// ADR-0003 -/// Amendment 3 names by hand. Four cases connect as -/// learnstack_migration: three read pg_catalog / -/// information_schema rather than data, and the fourth -/// () asserts a denial that -/// only the owner can experience. +/// Amendment 3 names by hand. The cases that connect as +/// learnstack_migration read pg_catalog / information_schema +/// rather than data; the one exception, +/// , asserts a denial only +/// the owner can experience and reads the truth through the platform role first. /// /// -/// Every table carries rows for both tenants. A count assertion against an -/// empty table passes whether or not the policy exists — that is how the first -/// version of the owner-denial case shipped proving nothing. The seed therefore -/// fills all eight, tenant A with a second organization so the organization half -/// of the template has something to hide, and every read case sweeps the whole -/// catalogue rather than a hand-written list. +/// Every table carries rows for both tenants, and the sweeps enumerate the +/// catalogue. A count assertion against an empty table passes whether or not +/// the policy exists, and a sweep over a hand-written list of names fails open for +/// the table nobody added to it — both were shipped, and both are recorded in +/// 's remarks. /// /// /// Three method names are not house style — @@ -42,30 +39,32 @@ namespace LearnStack.Tests.Integration.Database; /// /// [Trait(RequiresDocker.Key, RequiresDocker.Value)] -public sealed class TenancySchemaTests : IClassFixture +[Collection(SharedSchema.Name)] +public sealed class TenancySchemaTests { - private readonly TenancySchemaFixture _schema; + private readonly SchemaFixture _schema; - public TenancySchemaTests(TenancySchemaFixture schema) => _schema = schema; + public TenancySchemaTests(SchemaFixture schema) => _schema = schema; [Fact] public async Task EveryTableEnablesAndForcesRowLevelSecurity() { // The catalogue itself, not a list of names kept beside it. An inclusion // list fails open for the table somebody adds and forgets to add here — - // reproduced: a table created outside the array was invisible to this - // sweep and to the snake-case sweep at once, with row security off and a - // PascalCase column. + // reproduced twice: a table created outside the array was invisible to + // this sweep and to the snake-case sweep at once, and the sweeps + // themselves ran on a fixture carrying only one of the two chains, which + // narrowed every one of them to eight of the ten tables. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); - var scanned = await ReadStringsAsync(connection, TableCatalogueQuery); - scanned.Should().Contain(TenancySchemaFixture.KnownTables, - "the sweep must be reading the tenancy schema, not an empty result set"); + var scanned = await SchemaQueries.ReadStringsAsync(connection, SchemaQueries.TableNames); + scanned.Should().Contain(SchemaFixture.KnownTables, + "the sweep must be reading the whole applied schema, not an empty result set"); - var unprotected = await ReadStringsAsync(connection, + var unprotected = await SchemaQueries.ReadStringsAsync(connection, $""" SELECT relname FROM pg_class - WHERE oid IN ({TableCatalogueOids}) AND NOT (relrowsecurity AND relforcerowsecurity) + WHERE oid IN ({SchemaQueries.TableOids}) AND NOT (relrowsecurity AND relforcerowsecurity) """); unprotected.Should().BeEmpty("ENABLE without FORCE lets the owner bypass its own policies"); @@ -91,7 +90,7 @@ public async Task NoTableCarriesTwoPermissivePoliciesForOneCommand() // permissive policy has two policies for at least one command. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); - var offenders = await ReadStringsAsync(connection, + var offenders = await SchemaQueries.ReadStringsAsync(connection, """ SELECT tablename || ' (' || string_agg(policyname, ', ' ORDER BY policyname) || ')' FROM pg_policies @@ -114,21 +113,21 @@ public async Task Unsetting_tenant_context_returns_zero_rows_through_RLS() // false for USING and WITH CHECK alike. // // Swept over every table rather than asserted on `tenants` alone. Each of - // the eight holds rows for both tenants, so a table whose policy lost its + // the ten holds rows for both tenants, so a table whose policy lost its // predicate answers with a non-zero count here. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); - var counts = await CountEveryTableAsync(connection, transaction: null); + var counts = await SchemaQueries.CountEveryTableAsync(connection, transaction: null); counts.Should().OnlyContain(entry => entry.Value == 0, "with no app.tenant_id every policy predicate is NULL, which is false"); - counts.Keys.Should().Contain(TenancySchemaFixture.KnownTables); + counts.Keys.Should().Contain(SchemaFixture.KnownTables); } [Fact] public async Task Tenant_A_cannot_read_Tenant_B_data() { - // Both tenants hold rows in all eight tables, and tenant A holds a second + // Both tenants hold rows in all ten tables, and tenant A holds a second // organization, so every count here is a number only the correct policy // produces. A policy widened to USING (true) — the mutation that survived // the first version of this suite — shows up as A seeing B's rows. @@ -136,18 +135,18 @@ public async Task Tenant_A_cannot_read_Tenant_B_data() await using (var transaction = await connection.BeginTransactionAsync()) { - await SetTenantAsync(connection, transaction, TenancySchemaFixture.TenantA); - var seenByA = await CountEveryTableAsync(connection, transaction); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + var seenByA = await SchemaQueries.CountEveryTableAsync(connection, transaction); - seenByA.Should().BeEquivalentTo(TenancySchemaFixture.RowsVisibleToTenantA); + seenByA.Should().BeEquivalentTo(SchemaFixture.RowsVisibleToTenantA); } await using (var transaction = await connection.BeginTransactionAsync()) { - await SetTenantAsync(connection, transaction, TenancySchemaFixture.TenantB); - var seenByB = await CountEveryTableAsync(connection, transaction); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantB); + var seenByB = await SchemaQueries.CountEveryTableAsync(connection, transaction); - seenByB.Should().BeEquivalentTo(TenancySchemaFixture.RowsVisibleToTenantB); + seenByB.Should().BeEquivalentTo(SchemaFixture.RowsVisibleToTenantB); } } @@ -160,7 +159,7 @@ public async Task TenantWide_Row_Of_TenantB_Is_Invisible_To_TenantA() // key rather than on a count, so it names the row it is looking for. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); await using var transaction = await connection.BeginTransactionAsync(); - await SetTenantAsync(connection, transaction, TenancySchemaFixture.TenantA); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); await using var command = new NpgsqlCommand( "SELECT count(*) FROM tenant_settings WHERE key = 'beta-only' AND organization_id IS NULL", @@ -177,24 +176,36 @@ INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at VALUES (uuidv7(), @foreign, 'sneak', 'Sneak', 'Active', now(), @actor, 0) """)] [InlineData("UPDATE organizations SET tenant_id = @foreign WHERE tenant_id <> @foreign")] + [InlineData( + """ + INSERT INTO outbox_messages (tenant_id, correlation_id, type, topic, partition_key, payload) + VALUES (@foreign, '00-sneak-span-01', 'Sneak', 'learnstack.tenancy.tenant', 'k', '{}') + """)] + [InlineData( + """ + INSERT INTO idempotency_keys (tenant_id, key, fingerprint, claim_token, state, expires_at) + VALUES (@foreign, 'sneak-key-01', 'fp', uuidv7(), 'in_flight', now() + interval '5 minutes') + """)] public async Task Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck(string statement) { - // Both halves, because WITH CHECK guards both and a USING-only policy - // would pass the read-side cases while leaving either write open. The - // UPDATE runs under tenant B's context against tenant B's own row and - // tries to hand it to tenant A: USING admits the row, WITH CHECK refuses - // the new value. + // Every table whose policy carries a WITH CHECK, including the two the + // platform chain owns. `outbox_messages` is where the consequence is + // loudest: a handler that could enqueue into another tenant's stream would + // have its event delivered under that tenant's context by the dispatcher. + // The UPDATE case runs under tenant B's context against tenant B's own row + // and tries to hand it to tenant A — USING admits the row, WITH CHECK + // refuses the new value. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); await using var transaction = await connection.BeginTransactionAsync(); - await SetTenantAsync(connection, transaction, TenancySchemaFixture.TenantB); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantB); await using var command = new NpgsqlCommand( statement, (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); - command.Parameters.AddWithValue("foreign", TenancySchemaFixture.TenantA); + command.Parameters.AddWithValue("foreign", SchemaFixture.TenantA); if (statement.Contains("@actor", StringComparison.Ordinal)) { - command.Parameters.AddWithValue("actor", TenancySchemaFixture.Actor); + command.Parameters.AddWithValue("actor", SchemaFixture.Actor); } var act = async () => await command.ExecuteNonQueryAsync(); @@ -208,42 +219,82 @@ public async Task Org_X_cannot_read_Org_Y_within_TenantA() { // The organization half of the template, on the one org-scoped table this // packet ships. Tenant A holds a setting under each of its two - // organizations; a session scoped to the first must see its own and the - // tenant-wide row, and neither see nor touch the second's. + // organizations; a session scoped to the first must not see the second's. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await SchemaQueries.SetSettingAsync(connection, transaction, + "app.organization_id", SchemaFixture.OrgA1.ToString()); + + await using var read = new NpgsqlCommand( + "SELECT count(*) FROM tenant_settings WHERE organization_id = @other", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + read.Parameters.AddWithValue("other", SchemaFixture.OrgA2); + + (await read.ExecuteScalarAsync()).Should().Be(0L, + "organization A1 must not read organization A2's rows"); + } + + [Fact] + public async Task TheTenantScopeHatchWidensReadsAndNeitherWrite() + { + // `app.scope = 'tenant'` is the cross-organization READ hatch, and the two + // AS RESTRICTIVE guards are what stop it widening writes. Without this + // case both guards could be deleted with the whole suite green: under an + // ordinary organization-scoped session the base policy's own organization + // term already refuses the sibling row, so the guards are never the reason + // anything fails. Measured — with the hatch set and the delete guard + // dropped, DELETE removed organization A2's row; with the write guard + // dropped, an UPDATE reassigned it into the caller's own organization. // - // The three assertions are the three gates: USING for the read, the - // restrictive UPDATE guard, and the restrictive DELETE guard — DELETE has - // no WITH CHECK in PostgreSQL, so USING is its only gate and a widened one - // would let a sibling organization's row be removed. + // No caller sets app.scope yet (ITenantContext has no scope member; Packet + // 7 decides how it arrives), which is exactly why the guards need a test + // now rather than when the first one does. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); await using var transaction = await connection.BeginTransactionAsync(); - await SetTenantAsync(connection, transaction, TenancySchemaFixture.TenantA); - await SetSettingAsync(connection, transaction, - "app.organization_id", TenancySchemaFixture.OrgA1.ToString()); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await SchemaQueries.SetSettingAsync(connection, transaction, + "app.organization_id", SchemaFixture.OrgA1.ToString()); + await SchemaQueries.SetSettingAsync(connection, transaction, "app.scope", "tenant"); await using (var read = new NpgsqlCommand( "SELECT count(*) FROM tenant_settings WHERE organization_id = @other", (NpgsqlConnection)connection, (NpgsqlTransaction)transaction)) { - read.Parameters.AddWithValue("other", TenancySchemaFixture.OrgA2); - (await read.ExecuteScalarAsync()).Should().Be(0L, - "organization A1 must not read organization A2's rows"); + read.Parameters.AddWithValue("other", SchemaFixture.OrgA2); + (await read.ExecuteScalarAsync()).Should().Be(1L, + "the hatch is what makes cross-organization reporting possible"); } + // Three writes, because the three gates fail differently. An in-place + // UPDATE and a DELETE are refused by the restrictive guards; a re-parenting + // UPDATE — stealing a sibling's row into the caller's own organization — + // satisfies the base policy's WITH CHECK and is refused only by the write + // guard's USING. await using (var update = new NpgsqlCommand( "UPDATE tenant_settings SET value = '\"hijacked\"' WHERE organization_id = @other", (NpgsqlConnection)connection, (NpgsqlTransaction)transaction)) { - update.Parameters.AddWithValue("other", TenancySchemaFixture.OrgA2); + update.Parameters.AddWithValue("other", SchemaFixture.OrgA2); (await update.ExecuteNonQueryAsync()).Should().Be(0, "tenant_settings_org_write_guard is restrictive and admits no sibling row"); } + await using (var steal = new NpgsqlCommand( + "UPDATE tenant_settings SET organization_id = @mine WHERE organization_id = @other", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction)) + { + steal.Parameters.AddWithValue("mine", SchemaFixture.OrgA1); + steal.Parameters.AddWithValue("other", SchemaFixture.OrgA2); + (await steal.ExecuteNonQueryAsync()).Should().Be(0, + "and the guard's USING is what refuses a row the WITH CHECK would have accepted"); + } + await using (var delete = new NpgsqlCommand( "DELETE FROM tenant_settings WHERE organization_id = @other", (NpgsqlConnection)connection, (NpgsqlTransaction)transaction)) { - delete.Parameters.AddWithValue("other", TenancySchemaFixture.OrgA2); + delete.Parameters.AddWithValue("other", SchemaFixture.OrgA2); (await delete.ExecuteNonQueryAsync()).Should().Be(0, "tenant_settings_org_delete_guard is the only gate DELETE has"); } @@ -258,14 +309,14 @@ public async Task OrganizationIdIsImmutableAfterInsert() // organization_id is the caller's own, which is exactly this move. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); await using var transaction = await connection.BeginTransactionAsync(); - await SetTenantAsync(connection, transaction, TenancySchemaFixture.TenantA); - await SetSettingAsync(connection, transaction, - "app.organization_id", TenancySchemaFixture.OrgA1.ToString()); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await SchemaQueries.SetSettingAsync(connection, transaction, + "app.organization_id", SchemaFixture.OrgA1.ToString()); await using var command = new NpgsqlCommand( "UPDATE tenant_settings SET organization_id = @org WHERE key = 'tz'", (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); - command.Parameters.AddWithValue("org", TenancySchemaFixture.OrgA1); + command.Parameters.AddWithValue("org", SchemaFixture.OrgA1); var act = async () => await command.ExecuteNonQueryAsync(); @@ -309,13 +360,13 @@ public async Task TheResolvingHostAdmitsExactlyItsOwnRowBeforeAnyTenantContext() // wide by exactly one row. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); await using var transaction = await connection.BeginTransactionAsync(); - await SetSettingAsync(connection, transaction, "app.resolving_host", TenancySchemaFixture.HostA); + await SchemaQueries.SetSettingAsync(connection, transaction, "app.resolving_host", SchemaFixture.HostA); await using var command = new NpgsqlCommand( "SELECT string_agg(host, ',' ORDER BY host) FROM platform_host_to_tenant", (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); - (await command.ExecuteScalarAsync()).Should().Be(TenancySchemaFixture.HostA); + (await command.ExecuteScalarAsync()).Should().Be(SchemaFixture.HostA); } [Fact] @@ -335,27 +386,27 @@ public async Task ASoftDeletedDomainReleasesItsHostToAnotherTenant() await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); await using var transaction = await connection.BeginTransactionAsync(); - await SetTenantAsync(connection, transaction, TenancySchemaFixture.TenantA); - await ExecuteAsync(connection, transaction, + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await SchemaQueries.ExecuteAsync(connection, transaction, """ INSERT INTO tenant_domains (id, tenant_id, host, kind, status, verification_attempts, created_at, created_by, row_version) VALUES (uuidv7(), @tenant, @host, 'Custom', 'Requested', 0, now(), @actor, 0) """, - ("tenant", TenancySchemaFixture.TenantA), ("host", Host), ("actor", TenancySchemaFixture.Actor)); + ("tenant", SchemaFixture.TenantA), ("host", Host), ("actor", SchemaFixture.Actor)); - await ExecuteAsync(connection, transaction, + await SchemaQueries.ExecuteAsync(connection, transaction, "UPDATE tenant_domains SET deleted_at = now(), deleted_by = @actor WHERE host = @host", - ("actor", TenancySchemaFixture.Actor), ("host", Host)); + ("actor", SchemaFixture.Actor), ("host", Host)); - await SetTenantAsync(connection, transaction, TenancySchemaFixture.TenantB); - var reclaim = async () => await ExecuteAsync(connection, transaction, + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantB); + var reclaim = async () => await SchemaQueries.ExecuteAsync(connection, transaction, """ INSERT INTO tenant_domains (id, tenant_id, host, kind, status, verification_attempts, created_at, created_by, row_version) VALUES (uuidv7(), @tenant, @host, 'Custom', 'Requested', 0, now(), @actor, 0) """, - ("tenant", TenancySchemaFixture.TenantB), ("host", Host), ("actor", TenancySchemaFixture.Actor)); + ("tenant", SchemaFixture.TenantB), ("host", Host), ("actor", SchemaFixture.Actor)); await reclaim.Should().NotThrowAsync(); } @@ -371,14 +422,14 @@ public async Task EveryMappedIdentifierIsSnakeCase() // checks. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); - var scanned = await ReadStringsAsync(connection, TableCatalogueQuery); - scanned.Should().Contain(TenancySchemaFixture.KnownTables); + var scanned = await SchemaQueries.ReadStringsAsync(connection, SchemaQueries.TableNames); + scanned.Should().Contain(SchemaFixture.KnownTables); - var offenders = await ReadStringsAsync(connection, + var offenders = await SchemaQueries.ReadStringsAsync(connection, $""" SELECT c.relname || '.' || a.attname FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid - WHERE c.oid IN ({TableCatalogueOids}) + WHERE c.oid IN ({SchemaQueries.TableOids}) AND a.attnum > 0 AND NOT a.attisdropped AND a.attname <> lower(a.attname) """); @@ -387,16 +438,63 @@ AND a.attnum > 0 AND NOT a.attisdropped } [Fact] - public async Task TheGrantMatrixIsExactlyWhatTheMigrationWrote() + public async Task EveryForeignKeyHasASupportingIndex() { - // There is no ALTER DEFAULT PRIVILEGES, so every grant is one the migration - // wrote. All three grantees are asserted, not just the application role: + // Database Standards § Indexes: index every foreign key. Every foreign key + // in this schema is ON DELETE RESTRICT, so every parent delete pays the + // child scan. Swept rather than listed: the one that shipped without an + // index — fk_organizations_reporting_parent — was missed precisely because + // nothing swept. + // + // "Supporting" means one of two things, and both bound the scan: + // + // an index whose LEADING columns are the constraint's columns, in order + // — a trailing match does not serve the scan, which is why the + // comparison is a prefix slice rather than a containment test; or + // + // a UNIQUE index over a leading prefix of them. tenants' primary key is + // the case: fk_tenants_default_organization is composite on + // (id, default_organization_id), and a unique index on `id` alone + // already yields at most one candidate row, so the second column adds + // nothing an index could. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); + + var unindexed = await SchemaQueries.ReadStringsAsync(connection, + """ + SELECT c.conname || ' on ' || c.conrelid::regclass::text + FROM pg_constraint c + WHERE c.contype = 'f' + AND c.connamespace = 'public'::regnamespace + AND NOT EXISTS ( + SELECT 1 FROM pg_index i + WHERE i.indrelid = c.conrelid + AND ( + (i.indkey::int2[])[0:cardinality(c.conkey) - 1] = c.conkey + OR (i.indisunique + AND i.indnkeyatts <= cardinality(c.conkey) + AND (i.indkey::int2[])[0:i.indnkeyatts - 1] = c.conkey[1:i.indnkeyatts]) + ) + ) + ORDER BY 1 + """); + + unindexed.Should().BeEmpty("Standards 05 § Indexes: index every foreign key"); + } + + [Fact] + public async Task TheGrantMatrixIsExactlyWhatTheMigrationsWrote() + { + // There is no ALTER DEFAULT PRIVILEGES, so every grant is one a migration + // wrote. All three non-owner grantees are asserted, across both chains: // BYPASSRLS bypasses policies and not GRANTs, so for learnstack_platform // and learnstack_outbox_admin this matrix is the whole of the bound, and a - // widened grant on either is invisible in any other assertion. + // widened grant on either is invisible in any other assertion. Measured: + // granting learnstack_app table-wide UPDATE on outbox_messages let a + // handler mark every pending event processed — making them unpublishable — + // while every other assertion in the suite stayed green. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); - var grants = await ReadStringsAsync(connection, + var grants = await SchemaQueries.ReadStringsAsync(connection, """ SELECT grantee || ' ' || table_name || ' ' || string_agg(privilege_type, ',' ORDER BY privilege_type) FROM information_schema.role_table_grants @@ -415,6 +513,8 @@ AND grantee IN ('learnstack_app', 'learnstack_platform', 'learnstack_outbox_admi "learnstack_app tenant_feature_flags DELETE,INSERT,SELECT,UPDATE", "learnstack_app platform_entitlement_cache INSERT,SELECT,UPDATE", "learnstack_app platform_host_to_tenant DELETE,INSERT,SELECT,UPDATE", + "learnstack_app outbox_messages INSERT,SELECT", + "learnstack_app idempotency_keys INSERT,SELECT,UPDATE", "learnstack_platform tenants DELETE,INSERT,SELECT,UPDATE", "learnstack_platform organizations DELETE,INSERT,SELECT,UPDATE", "learnstack_platform tenant_domains SELECT", @@ -423,343 +523,11 @@ AND grantee IN ('learnstack_app', 'learnstack_platform', 'learnstack_outbox_admi "learnstack_platform tenant_feature_flags DELETE,INSERT,SELECT,UPDATE", "learnstack_platform platform_entitlement_cache DELETE,SELECT", "learnstack_platform platform_host_to_tenant DELETE,INSERT,SELECT,UPDATE", + "learnstack_platform outbox_messages DELETE,SELECT", + "learnstack_platform idempotency_keys DELETE,SELECT", + "learnstack_outbox_admin outbox_messages SELECT", ], - "learnstack_outbox_admin owns nothing in the tenancy schema, and every other " - + "grant is one line of the migration's matrix"); - } - - // ── Helpers ────────────────────────────────────────────────────────────── - - /// - /// Every ordinary table in schema public, minus EF's history table. - /// - /// - /// The only name written down, and it is written down because it is the one - /// table that is not part of the schema under test: it carries - /// MigrationId and ProductVersion, both PascalCase, and no row - /// security by design. - /// - private const string TableCatalogueOids = - """ - SELECT oid FROM pg_class - WHERE relnamespace = 'public'::regnamespace AND relkind = 'r' - AND relname NOT LIKE '\_\_ef%' - """; - - private const string TableCatalogueQuery = - """ - SELECT relname FROM pg_class - WHERE relnamespace = 'public'::regnamespace AND relkind = 'r' - AND relname NOT LIKE '\_\_ef%' - ORDER BY relname - """; - - private static async Task> CountEveryTableAsync( - System.Data.Common.DbConnection connection, - System.Data.Common.DbTransaction? transaction) - { - var counts = new Dictionary(StringComparer.Ordinal); - - foreach (var table in await ReadStringsAsync(connection, TableCatalogueQuery, transaction)) - { - // The table name is a pg_class.relname read from the same connection - // moments earlier, not caller input; there is no bind parameter for an - // identifier, and quote_ident is what makes the interpolation safe. - await using var command = new NpgsqlCommand( - $"SELECT count(*) FROM {Quote(table)}", - (NpgsqlConnection)connection, (NpgsqlTransaction?)transaction); - - counts[table] = (long)(await command.ExecuteScalarAsync())!; - } - - return counts; - } - - private static string Quote(string identifier) => - "\"" + identifier.Replace("\"", "\"\"", StringComparison.Ordinal) + "\""; - - private static async Task> ReadStringsAsync( - System.Data.Common.DbConnection connection, - string sql, - System.Data.Common.DbTransaction? transaction = null) - { - await using var command = new NpgsqlCommand( - sql, (NpgsqlConnection)connection, (NpgsqlTransaction?)transaction); - - var values = new List(); - await using var reader = await command.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - { - values.Add(reader.GetString(0)); - } - - return values; - } - - private static async Task ExecuteAsync( - System.Data.Common.DbConnection connection, - System.Data.Common.DbTransaction transaction, - string sql, - params (string Name, object Value)[] parameters) - { - await using var command = new NpgsqlCommand( - sql, (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); - - foreach (var (name, value) in parameters) - { - command.Parameters.AddWithValue(name, value); - } - - - await command.ExecuteNonQueryAsync(); + "every line is one line of the two migrations' grant matrices, and " + + "learnstack_outbox_admin holds nothing beyond the outbox"); } - - private static Task SetTenantAsync( - System.Data.Common.DbConnection connection, - System.Data.Common.DbTransaction transaction, - Guid tenantId) - => SetSettingAsync(connection, transaction, "app.tenant_id", tenantId.ToString()); - - private static async Task SetSettingAsync( - System.Data.Common.DbConnection connection, - System.Data.Common.DbTransaction transaction, - string name, - string value) - { - // set_config(..., true) rather than SET LOCAL: PostgreSQL's SET takes no - // bind parameter — `SET LOCAL x = $1` is a syntax error, measured — and the - // third argument `true` is what makes it transaction-local. The transaction - // is passed explicitly because that locality is the whole point: a setting - // applied to the wrong transaction is applied to nothing. - await using var command = new NpgsqlCommand( - "SELECT set_config(@name, @value, true)", - (NpgsqlConnection)connection, - (NpgsqlTransaction)transaction); - command.Parameters.AddWithValue("name", name); - command.Parameters.AddWithValue("value", value); - await command.ExecuteNonQueryAsync(); - } -} - -/// -/// Applies the tenancy migration once and seeds every table for both -/// tenants. -/// -/// -/// -/// A count assertion against an empty table passes whether or not the policy that -/// should have emptied it exists, so a partial seed silently turns isolation cases -/// into tautologies. Every one of the eight tables therefore carries rows for -/// tenant A and tenant B, and tenant A carries a second organization so the -/// organization half of the template has a sibling row to hide. -/// -/// -/// Most of the seed runs as learnstack_migration inside a transaction that -/// sets app.tenant_id — the only way to insert, since every table's -/// WITH CHECK is live from the moment the migration finishes. -/// platform_host_to_tenant is the exception: its policies are qualified -/// TO learnstack_app, so the owner is denied on it and its rows go in -/// through the application role. -/// -/// -public sealed class TenancySchemaFixture : IAsyncLifetime -{ - public static readonly Guid TenantA = Guid.Parse("11111111-1111-7111-8111-111111111111"); - public static readonly Guid TenantB = Guid.Parse("22222222-2222-7222-8222-222222222222"); - public static readonly Guid OrgA1 = Guid.Parse("aaaaaaaa-1111-7111-8111-111111111111"); - public static readonly Guid OrgA2 = Guid.Parse("aaaaaaaa-2222-7222-8222-222222222222"); - public static readonly Guid OrgB1 = Guid.Parse("bbbbbbbb-1111-7111-8111-111111111111"); - public static readonly Guid Actor = Guid.Parse("00000000-0000-7000-8000-000000000001"); - - public const string HostA = "alpha.example.com"; - public const string HostB = "beta.example.com"; - - /// - /// The eight tables this migration creates, used only to prove that a - /// catalogue sweep read something. - /// - /// - /// Not an inclusion list: no query filters on it. It exists so a sweep that - /// silently matched nothing fails instead of passing, which is the other way a - /// structural assertion can prove nothing. - /// - public static readonly string[] KnownTables = - [ - "tenants", "organizations", "tenant_domains", "tenant_locales", - "tenant_settings", "tenant_feature_flags", - "platform_entitlement_cache", "platform_host_to_tenant", - ]; - - /// What tenant A sees with its tenant context set and no organization scope. - public static readonly Dictionary RowsVisibleToTenantA = new(StringComparer.Ordinal) - { - ["tenants"] = 1, - ["organizations"] = 2, - ["tenant_domains"] = 1, - ["tenant_locales"] = 1, - // Three rows exist; two are organization-scoped and invisible without - // app.organization_id. Org_X_cannot_read_Org_Y_within_TenantA reads those. - ["tenant_settings"] = 1, - ["tenant_feature_flags"] = 1, - ["platform_entitlement_cache"] = 1, - ["platform_host_to_tenant"] = 1, - }; - - /// What tenant B sees with its tenant context set. - public static readonly Dictionary RowsVisibleToTenantB = new(StringComparer.Ordinal) - { - ["tenants"] = 1, - ["organizations"] = 1, - ["tenant_domains"] = 1, - ["tenant_locales"] = 1, - ["tenant_settings"] = 1, - ["tenant_feature_flags"] = 1, - ["platform_entitlement_cache"] = 1, - ["platform_host_to_tenant"] = 1, - }; - - public PostgresFixture Postgres { get; } = new(); - - public async Task InitializeAsync() - { - await Postgres.InitializeAsync(); - - var options = new DbContextOptionsBuilder() - .UseNpgsql(Postgres.MigrationConnectionString, npgsql => - npgsql.MigrationsHistoryTable("__ef_migrations_history_tenancy")) - .Options; - - await using (var context = new TenancyDbContext(options)) - { - await context.Database.MigrateAsync(); - } - - await SeedAsync(); - } - - public async Task DisposeAsync() => await Postgres.DisposeAsync(); - - private async Task SeedAsync() - { - await using (var owner = await PostgresFixture.OpenAsync(Postgres.MigrationConnectionString)) - { - await using var command = new NpgsqlCommand(TenantRowsSql, (NpgsqlConnection)owner); - await command.ExecuteNonQueryAsync(); - } - - // platform_host_to_tenant only: its four policies are role-qualified TO - // learnstack_app, so under FORCE the owner is denied on it. - await using var app = await PostgresFixture.OpenAsync(Postgres.AppConnectionString); - await using var mappings = new NpgsqlCommand(HostMappingsSql, (NpgsqlConnection)app); - await mappings.ExecuteNonQueryAsync(); - } - - private const string TenantRowsSql = - """ - BEGIN; - SET LOCAL app.tenant_id = '11111111-1111-7111-8111-111111111111'; - - INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) - VALUES ('11111111-1111-7111-8111-111111111111','alpha','Alpha','Trial', now(), - '00000000-0000-7000-8000-000000000001', 0); - - INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) - VALUES ('aaaaaaaa-1111-7111-8111-111111111111','11111111-1111-7111-8111-111111111111', - 'main','Main','Active', now(), '00000000-0000-7000-8000-000000000001', 0), - ('aaaaaaaa-2222-7222-8222-222222222222','11111111-1111-7111-8111-111111111111', - 'branch','Branch','Active', now(), '00000000-0000-7000-8000-000000000001', 0); - - UPDATE tenants SET default_organization_id = 'aaaaaaaa-1111-7111-8111-111111111111' - WHERE id = '11111111-1111-7111-8111-111111111111'; - - INSERT INTO tenant_domains - (id, tenant_id, host, kind, status, verification_attempts, created_at, created_by, row_version) - VALUES (uuidv7(),'11111111-1111-7111-8111-111111111111','alpha.example.com','Subdomain','Verified', - 0, now(), '00000000-0000-7000-8000-000000000001', 0); - - INSERT INTO tenant_locales (tenant_id, locale, is_default, is_enabled, sort) - VALUES ('11111111-1111-7111-8111-111111111111','tr-TR', true, true, 0); - - INSERT INTO tenant_settings (id, tenant_id, organization_id, key, value, created_at, created_by, row_version) - VALUES (uuidv7(),'11111111-1111-7111-8111-111111111111', NULL, - 'tz', '"Europe/Istanbul"', now(), '00000000-0000-7000-8000-000000000001', 0); - - -- One organization at a time, because the org-scoped WITH CHECK admits a - -- row only under its own organization's context. Writing both in one - -- statement is exactly what the guard refuses, and the seed is the first - -- place that shows it. - SET LOCAL app.organization_id = 'aaaaaaaa-1111-7111-8111-111111111111'; - INSERT INTO tenant_settings (id, tenant_id, organization_id, key, value, created_at, created_by, row_version) - VALUES (uuidv7(),'11111111-1111-7111-8111-111111111111','aaaaaaaa-1111-7111-8111-111111111111', - 'theme', '"main"', now(), '00000000-0000-7000-8000-000000000001', 0); - - SET LOCAL app.organization_id = 'aaaaaaaa-2222-7222-8222-222222222222'; - INSERT INTO tenant_settings (id, tenant_id, organization_id, key, value, created_at, created_by, row_version) - VALUES (uuidv7(),'11111111-1111-7111-8111-111111111111','aaaaaaaa-2222-7222-8222-222222222222', - 'theme', '"branch"', now(), '00000000-0000-7000-8000-000000000001', 0); - - INSERT INTO tenant_feature_flags (tenant_id, key, value, updated_by) - VALUES ('11111111-1111-7111-8111-111111111111','live-classroom','true', - '00000000-0000-7000-8000-000000000001'); - - INSERT INTO platform_entitlement_cache - (tenant_id, plan_code, features, limits, compliance, valid_until, source) - VALUES ('11111111-1111-7111-8111-111111111111','pro','{}','{}','{}', - now() + interval '30 days','null-provider'); - COMMIT; - - BEGIN; - SET LOCAL app.tenant_id = '22222222-2222-7222-8222-222222222222'; - - INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) - VALUES ('22222222-2222-7222-8222-222222222222','beta','Beta','Trial', now(), - '00000000-0000-7000-8000-000000000001', 0); - - INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) - VALUES ('bbbbbbbb-1111-7111-8111-111111111111','22222222-2222-7222-8222-222222222222', - 'main','Main','Active', now(), '00000000-0000-7000-8000-000000000001', 0); - - UPDATE tenants SET default_organization_id = 'bbbbbbbb-1111-7111-8111-111111111111' - WHERE id = '22222222-2222-7222-8222-222222222222'; - - INSERT INTO tenant_domains - (id, tenant_id, host, kind, status, verification_attempts, created_at, created_by, row_version) - VALUES (uuidv7(),'22222222-2222-7222-8222-222222222222','beta.example.com','Subdomain','Verified', - 0, now(), '00000000-0000-7000-8000-000000000001', 0); - - INSERT INTO tenant_locales (tenant_id, locale, is_default, is_enabled, sort) - VALUES ('22222222-2222-7222-8222-222222222222','en-US', true, true, 0); - - INSERT INTO tenant_settings (id, tenant_id, organization_id, key, value, created_at, created_by, row_version) - VALUES (uuidv7(),'22222222-2222-7222-8222-222222222222', NULL, - 'beta-only', '"visible to beta alone"', now(), - '00000000-0000-7000-8000-000000000001', 0); - - INSERT INTO tenant_feature_flags (tenant_id, key, value, updated_by) - VALUES ('22222222-2222-7222-8222-222222222222','live-classroom','false', - '00000000-0000-7000-8000-000000000001'); - - INSERT INTO platform_entitlement_cache - (tenant_id, plan_code, features, limits, compliance, valid_until, source) - VALUES ('22222222-2222-7222-8222-222222222222','free','{}','{}','{}', - now() + interval '30 days','null-provider'); - COMMIT; - """; - - private const string HostMappingsSql = - """ - BEGIN; - SET LOCAL app.tenant_id = '11111111-1111-7111-8111-111111111111'; - INSERT INTO platform_host_to_tenant (host, tenant_id, organization_id, is_active, is_publicly_live) - VALUES ('alpha.example.com','11111111-1111-7111-8111-111111111111', - 'aaaaaaaa-1111-7111-8111-111111111111', true, true); - COMMIT; - - BEGIN; - SET LOCAL app.tenant_id = '22222222-2222-7222-8222-222222222222'; - INSERT INTO platform_host_to_tenant (host, tenant_id, organization_id, is_active, is_publicly_live) - VALUES ('beta.example.com','22222222-2222-7222-8222-222222222222', - 'bbbbbbbb-1111-7111-8111-111111111111', true, true); - COMMIT; - """; } diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs new file mode 100644 index 00000000..8b0dc4da --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs @@ -0,0 +1,168 @@ +using FluentAssertions; +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Time; +using Xunit; + +namespace LearnStack.Tests.Unit.Modules.Tenancy; + +/// +/// The Tenancy aggregates' factories and the invariants they refuse to let a +/// caller past. +/// +/// +/// The schema carries most of these as constraints, and that is the second layer +/// rather than the first: a caller that reaches the database has already built an +/// object the domain says cannot exist, and gets a +/// PostgresException three layers from the mistake instead of an +/// ArgumentException at it. +/// +public sealed class TenancyAggregateTests +{ + private static readonly FixedClock Clock = new( + new DateTimeOffset(2026, 8, 28, 9, 0, 0, TimeSpan.Zero)); + + private static readonly UserId Actor = + UserId.From(Guid.Parse("00000000-0000-7000-8000-000000000001")); + + private static readonly TenantId Tenant = + TenantId.From(Guid.Parse("11111111-1111-7111-8111-111111111111")); + + private static readonly TenantDomainId DomainId = + TenantDomainId.From(Guid.Parse("dddddddd-1111-7111-8111-111111111111")); + + [Fact] + public void A_subdomain_is_verified_by_construction() + { + var domain = TenantDomain.CreateSubdomain( + DomainId, Tenant, "alpha.example.com", Clock, Actor); + + domain.Kind.Should().Be(TenantDomainKind.Subdomain); + domain.Status.Should().Be(TenantDomainStatus.Verified); + domain.VerifiedAt.Should().Be(Clock.UtcNow); + } + + [Fact] + public void A_custom_domain_starts_unverified() + { + var domain = TenantDomain.RequestCustomDomain( + DomainId, Tenant, "learn.acme.com", Clock, Actor); + + domain.Kind.Should().Be(TenantDomainKind.Custom); + domain.Status.Should().Be(TenantDomainStatus.Requested); + domain.VerifiedAt.Should().BeNull(); + } + + [Fact] + public void A_subdomain_has_no_verification_lifecycle() + { + // The platform controls the zone, so a Subdomain is Verified by + // construction and every state diagram in the corpus draws it that way. + // Nothing in the schema says so — ck_tenant_domains_kind and + // ck_tenant_domains_status are independent single-column CHECKs — so the + // aggregate is where the invariant lives. + var domain = TenantDomain.CreateSubdomain( + DomainId, Tenant, "alpha.example.com", Clock, Actor); + + var verify = () => domain.MarkVerified(Clock, Actor); + var fail = () => domain.MarkVerificationFailed("dns", Clock, Actor); + + verify.Should().Throw().WithMessage("*verified by construction*"); + fail.Should().Throw().WithMessage("*verified by construction*"); + domain.Status.Should().Be(TenantDomainStatus.Verified); + } + + [Fact] + public void A_custom_domain_records_its_verification_attempts() + { + var domain = TenantDomain.RequestCustomDomain( + DomainId, Tenant, "learn.acme.com", Clock, Actor); + + domain.MarkVerificationFailed("no TXT record", Clock, Actor); + + domain.Status.Should().Be(TenantDomainStatus.Failed); + domain.VerificationAttempts.Should().Be(1); + domain.LastVerificationError.Should().Be("no TXT record"); + + domain.MarkVerified(Clock, Actor); + + domain.Status.Should().Be(TenantDomainStatus.Verified); + domain.VerificationAttempts.Should().Be(2); + domain.LastVerificationError.Should().BeNull("a success clears the previous failure"); + domain.Version.Should().Be(2, "each attempt is an audited, versioned mutation"); + } + + [Theory] + // The whole rule, not the case half of it. An earlier guard tested only + // `host.Any(char.IsUpper)` while its message named four properties, so a host + // carrying a port or an IDN label passed the aggregate and failed at + // ck_tenant_domains_host_normalized three layers down. + [InlineData("Alpha.Example.com")] // not lowercase + [InlineData("alpha.example.com:443")] // carries a port + [InlineData("alpha.example.com.")] // trailing dot + [InlineData("täst.example.com")] // not punycoded + [InlineData("-bad.example.com")] // not a host at all + [InlineData("a..b.example.com")] // empty label + public void A_host_that_is_not_already_normalized_is_refused(string host) + { + var create = () => TenantDomain.RequestCustomDomain(DomainId, Tenant, host, Clock, Actor); + + create.Should().Throw().WithMessage("*normalized*"); + } + + [Theory] + [InlineData("alpha.example.com")] + [InlineData("xn--tst-qla.example.com")] + public void An_already_normalized_host_is_accepted(string host) + { + var create = () => TenantDomain.RequestCustomDomain(DomainId, Tenant, host, Clock, Actor); + + create.Should().NotThrow(); + } + + [Fact] + public void Every_factory_refuses_an_unassigned_identifier() + { + // The two direct misuse paths — `default` and `new` — are compile-time + // errors under Vogen (VOG009 / VOG010), so this guard exists for the third: + // an id that travelled through a `default(T)`-shaped generic or a + // deserializer. Tenant.Create already had it; the other three did not, and + // an inconsistent guard reads as a deliberate exemption. + var organization = () => Organization.Create( + Unassigned(), Tenant, "main", "Main", Clock, Actor); + var domain = () => TenantDomain.RequestCustomDomain( + Unassigned(), Tenant, "learn.acme.com", Clock, Actor); + var setting = () => TenantSetting.Create( + Unassigned(), Tenant, null, "tz", "\"Europe/Istanbul\"", Clock, Actor); + + organization.Should().Throw().WithMessage("*never assigned*"); + domain.Should().Throw().WithMessage("*never assigned*"); + setting.Should().Throw().WithMessage("*never assigned*"); + } + + [Fact] + public void Every_factory_refuses_an_unassigned_tenant() + { + var organization = () => Organization.Create( + OrganizationId.From(Guid.Parse("aaaaaaaa-1111-7111-8111-111111111111")), + Unassigned(), "main", "Main", Clock, Actor); + var domain = () => TenantDomain.RequestCustomDomain( + DomainId, Unassigned(), "learn.acme.com", Clock, Actor); + + organization.Should().Throw(); + domain.Should().Throw(); + } + + /// + /// An identifier nobody assigned, obtained the only way that compiles. + /// + /// + /// Writing default(TenantId) at a call site is a compile error under + /// Vogen (VOG009), which is the point — the guard under test exists for the + /// path the analyzer cannot see, where the zero value arrives through a + /// generic default(T) or a deserializer. This helper is that path, + /// reproduced. + /// + private static T Unassigned() + where T : struct => default; +} diff --git a/docs/decisions/0037-idempotency-key-contract.md b/docs/decisions/0037-idempotency-key-contract.md index 9666a533..ced03e21 100644 --- a/docs/decisions/0037-idempotency-key-contract.md +++ b/docs/decisions/0037-idempotency-key-contract.md @@ -472,7 +472,10 @@ ON CONFLICT (tenant_id, key) DO UPDATE SET status_code = CASE WHEN idempotency_keys.expires_at <= now() THEN NULL ELSE idempotency_keys.status_code END, content_type = CASE WHEN idempotency_keys.expires_at <= now() THEN NULL ELSE idempotency_keys.content_type END, headers = CASE WHEN idempotency_keys.expires_at <= now() THEN NULL ELSE idempotency_keys.headers END, - body = CASE WHEN idempotency_keys.expires_at <= now() THEN NULL ELSE idempotency_keys.body END + body = CASE WHEN idempotency_keys.expires_at <= now() THEN NULL ELSE idempotency_keys.body END, + -- Amendment 3: without this the reclaimed row reports a new fence against the + -- timestamp of a claim several reclaims ago. + claimed_at = CASE WHEN idempotency_keys.expires_at <= now() THEN EXCLUDED.claimed_at ELSE idempotency_keys.claimed_at END RETURNING (xmax = 0) AS inserted, state, fingerprint, claim_token, status_code, content_type, headers, body; ``` @@ -502,6 +505,48 @@ object to compare. `TryClaimAsync` takes no caller-supplied token precisely so t comparison cannot be skipped at a call site: only the store knows the value it minted. +### Amendment 3 — `claimed_at` on reclaim, and the outcome CHECK (2026-08-28) + +Two corrections found by measuring Amendment 2's statement against the shipped +table. + +**`claimed_at` is never refreshed.** The `DO UPDATE SET` list replaces the row's +whole identity on the reclaim branch — `fingerprint`, `claim_token`, `state`, +`expires_at`, and all four response columns NULLed — and does not touch +`claimed_at`, whose only writer is the initial insert's `DEFAULT now()`. A +reclaimed row therefore reports a new fence, a new lease and a new request against +the timestamp of a claim several reclaims ago. Nothing reads the column yet, which +is exactly why it is cheap to fix and easy to leave wrong: it is the column an +operator traces a duplicate side effect with. The `SET` list gains one line, in the +same shape as every other: + +```sql + claimed_at = CASE WHEN idempotency_keys.expires_at <= now() THEN EXCLUDED.claimed_at ELSE idempotency_keys.claimed_at END, +``` + +**The state and the response columns are one fact, and the database now says so.** +Nothing tied `state` to `status_code` / `content_type` / `headers` / `body`: a +`completed` row could carry all four NULL, and the claim statement would report it +as `Completed`, which its own outcome table defines as "replay the four response +columns" — the caller then replays a response that does not exist. The reverse was +equally free: an `unreplayable` tombstone could carry a full `201` body. The +canonical DDL and the shipped migration both gain + +```sql + CONSTRAINT ck_idempotency_keys_outcome CHECK ( + (state = 'completed' AND status_code IS NOT NULL AND body IS NOT NULL) + OR (state <> 'completed' AND status_code IS NULL AND content_type IS NULL + AND headers IS NULL AND body IS NULL)) +``` + +`content_type` stays free in the completed arm, because the port defines it as null +for an empty body. The constraint matches the reclaim branch above, which already +NULLs all four alongside `state = 'in_flight'`. + +Neither changes what this ADR decides. `PostgresIdempotencyStore` does not exist +yet — it is demand-gated on the trigger Amendment 1 names — so both land while the +table has no rows and the constraint validates instantly. + ## References - [ADR-0035: Demand-Gated Infrastructure](0035-demand-gated-infrastructure.md) diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index 8f8dab4d..b2ec38a5 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -246,10 +246,16 @@ request and are the only Tenancy work an anonymous visitor pays for. ## Risks and open questions - **`app.scope` has no carrier.** `ITenantContext` exposes no scope member, so - nothing sets `app.scope = 'tenant'` and the cross-organization read hatch on - `tenant_settings` is currently unreachable. That is the correct default; - [Packet 7](../../roadmap/phase-02a-kernel-tenancy.md) decides how the flag - arrives ([ADR-0040 Amendment 1](../../decisions/0040-ambient-unit-of-work.md)). + no application path sets `app.scope = 'tenant'` and the cross-organization read + hatch on `tenant_settings` is unreachable at runtime. That is the correct + default; [Packet 7](../../roadmap/phase-02a-kernel-tenancy.md) decides how the + flag arrives ([ADR-0040 Amendment 1](../../decisions/0040-ambient-unit-of-work.md)). + The two `AS RESTRICTIVE` write guards are tested **now** rather than then — + `TheTenantScopeHatchWidensReadsAndNeitherWrite` sets the flag directly — because + under any ordinary organization-scoped session the base policy's own + organization term already refuses a sibling's row, so both guards could be + deleted with the whole suite green. Measured: with the hatch set and the delete + guard dropped, a `DELETE` removed another organization's row. - **No query filters yet.** The EF tenant and organization filters land in Packet 7 with `TenantResolverMiddleware`. Between the packets nothing reads a tenant-owned table on a request path, and with `app.tenant_id` unset every diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 4f35a600..7b474ec9 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -1029,7 +1029,18 @@ CREATE TABLE idempotency_keys ( CHECK (body IS NULL OR octet_length(body) <= 262144), -- Matches [Idempotent]'s header bounds, so a key the API accepted always fits. CONSTRAINT ck_idempotency_keys_key_length - CHECK (length(key) BETWEEN 8 AND 128) + CHECK (length(key) BETWEEN 8 AND 128), + -- The state and the response columns are one fact, not two. ADR-0037 + -- Amendment 2's claim statement reports a `completed` row as replayable, so a + -- `completed` row with no status code and no body makes the caller replay a + -- response that does not exist; and the reclaim branch NULLs all four + -- alongside `state = 'in_flight'`, so the reverse is equally a lie about what + -- the row is. content_type stays free in the completed arm — the port defines + -- it as null for an empty body. + CONSTRAINT ck_idempotency_keys_outcome CHECK ( + (state = 'completed' AND status_code IS NOT NULL AND body IS NOT NULL) + OR (state <> 'completed' AND status_code IS NULL AND content_type IS NULL + AND headers IS NULL AND body IS NULL)) ); -- Serves both the retention sweep and the per-tenant admission count, tenant first diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 0f7e7a20..8d07485d 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -673,6 +673,39 @@ rules that need a second `DbContext` are owed by Phase 03. - **Status:** **Implemented** (Packet 6 step 4, `LearnStack.Tests.Architecture`, `PersistenceConventionTests`). +#### `Migrate_Target_Covers_Every_Migration_Chain` + +- **Asserts:** every directory under `backend/src` carrying a + `Persistence/Migrations` folder is reachable from the `migrate` recipe's project + loop in the repo-root `Makefile` — scanned, not listed, so adding a chain and + forgetting the recipe fails here. `make migrate` is the only path + [05-database.md § Database roles](05-database.md) documents for applying a + migration; its first version globbed `src/Modules` only, which left the platform + chain unapplied everywhere except the Testcontainers fixtures, which call + `Database.MigrateAsync()` directly and stayed green. +- **Source:** [05-database.md § Migrations](05-database.md); the `migrate` target. +- **Type:** xUnit + Makefile and directory inspection. **Kind:** structural. +- **Status:** **Implemented** (Packet 6 step 5, + `LearnStack.Tests.Architecture`, `PersistenceConventionTests`). + Mutation-checked: narrowing the loop back to `src/Modules` fails this case. + +#### `Every_Foreign_Key_Has_A_Supporting_Index` + +- **Asserts:** every foreign key in schema `public` has an index whose **leading** + columns are the constraint's columns, or a **unique** index over a leading prefix + of them — a unique prefix already yields at most one candidate row, which is why + `tenants`' primary key supports the composite + `fk_tenants_default_organization`. Every foreign key in this schema is + `ON DELETE RESTRICT`, so every parent delete pays the child scan. +- **Source:** [05-database.md § Indexes](05-database.md). +- **Type:** **integration** test (Testcontainers + PostgreSQL), reading + `pg_constraint` / `pg_index`. **Kind:** structural. +- **Status:** **Implemented** (Packet 6 step 5, + `LearnStack.Tests.Integration`, `TenancySchemaTests`). It found two real gaps on + its first run — `fk_organizations_reporting_parent` and + `fk_platform_host_to_tenant_organization` — which is the evidence that it is not + vacuous. + #### `SoftDelete_Advances_The_Row_Version` - **Asserts:** `AuditableEntity.SoftDelete` leaves `Version` strictly greater than it From 3b6b9cb8a1981e087f521891775f832a84c1e0d3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 13:56:12 +0300 Subject: [PATCH 17/35] fix(infra): close the Step 5 Sonnet round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One confirmed finding, and three of the four lenses came back empty — the two earlier rounds had already taken the schema, the isolation surface and the suite apart. `InMemoryIdempotencyStore` and its registration both said the durable store "lands with the schema in Packet 6". ADR-0037 Amendment 1 corrected exactly that coupling on 2026-08-27 and neither comment was updated — including by the commit that added Amendment 3 to the same ADR. The table is a one-way door and shipped now; the store is additive and ships on its ADR-0035 trigger, which is the distinction the Amendment exists to make and which these two comments were quietly undoing. Module: Tenancy Co-Authored-By: Claude Opus 5 (1M context) --- .../Tenancy/TenancyCompositionExtensions.cs | 9 ++++++--- .../Idempotency/InMemoryIdempotencyStore.cs | 14 +++++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs b/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs index 4cb1d3b9..6d714274 100644 --- a/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Tenancy/TenancyCompositionExtensions.cs @@ -186,9 +186,12 @@ public static IServiceCollection AddLearnStackTenancyEdge( services.AddSingleton(); // The only registered IIdempotencyStore. Correct for one instance and - // wrong for two — the durable implementation lands with the schema in - // Packet 6, and Standards 04's "required for payment operations" list - // has no member before then. + // wrong for two, and it stays registered anyway: ADR-0037 Amendment 1 + // separates the table from the store. Packet 6 ships idempotency_keys + // because the schema is a one-way door; the durable store is additive and + // ships on its ADR-0035 trigger — the first [Idempotent] endpoint, or the + // first deployment running more than one instance. Standards 04's + // "required for payment operations" list has no member yet. services.AddSingleton(); diff --git a/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs b/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs index ea9be4ba..8945d03a 100644 --- a/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs +++ b/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs @@ -16,10 +16,18 @@ namespace LearnStack.Infrastructure.Idempotency; /// is precisely what an idempotency key exists to prevent. Per /// ADR-0037 /// that is acceptable only while there is one instance and no endpoint yet -/// requires the header; the durable implementation lands with the schema in +/// requires the header, and Standards 04's "required for payment operations" +/// list has no member yet. +/// +/// +/// The table and the store ship apart. ADR-0037 Amendment 1 separates +/// them: idempotency_keys is one-way-door schema and shipped with /// Packet 6, -/// and Standards 04's "required for payment operations" list has no member -/// before then. +/// while the durable store is additive and ships on its +/// ADR-0035 +/// trigger — the first [Idempotent] endpoint, or the first deployment +/// running more than one instance. This implementation stays registered until +/// then. /// /// /// The same limitation is why ICacheService exists as a port and why From 0a24f26569878c90244e5e71aac9c6d16cb8f84c Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 14:05:52 +0300 Subject: [PATCH 18/35] feat(kernel): the ambient unit of work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0040's seam, and the last thing Packet 6 owed before Packet 7 can resolve a tenant into it. `IUnitOfWork` is one database connection per scope and the transaction on it; every module `DbContext` is built on that connection and enlisted in that transaction, and `IAuditStore` and `IOutbox` will reach the same connection through the same seam. The reason it is a correctness property rather than a performance one: `SET LOCAL app.tenant_id` is connection- and transaction-local, so a context that opened its own connection never saw it, and under the corrected Row Level Security policy every read through it returns zero rows — silently, because a policy that filters everything is indistinguishable from a table with no matching data. `UnitOfWorkTests` measures both halves against a real PostgreSQL: a context resolved through the shared helper sees a row written on the ambient connection inside the same uncommitted transaction, and an unresolved tenant context leaves every tenant-owned table empty. Nesting is a depth, not a boolean, and that is forced by the shape of the behavior rather than chosen: `TransactionBehavior` calls `CommitAsync` directly rather than through the handle, and ADR-0040 § Nesting says a nested frame "never commits, never rolls back". Only a frame counter makes that true of a bare `CommitAsync`. An inner rollback marks the unit rollback-only, so the outer commit throws instead of committing a partial unit, and a scope that ends with a live transaction rolls it back — committing there would commit work nobody claimed was finished. `AddModuleDbContext` is the only sanctioned registration, and it throws when a context is resolved outside the transaction rather than handing back one that reads nothing and cannot say why. `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork` guards it from both sides: the composition root's registrations, and the fact that exactly three files under `backend/src` may mention `UseNpgsql` at all. `TransactionBehavior`'s body replaces the Packet 3 shell. It has no gate, per the ADR — everything reaching step 6 needs a transaction, because the requests that must not open one have already short-circuited — and the MUST-class audit write has its line reserved, immediately before the commit, for Packet 9. One test-harness consequence, stated rather than hidden: `CrossCuttingHttpFixture` is a `WebApplicationFactory` in the non-Docker job, and step 6 now opens a real transaction on every request that reaches it. It replaces `IUnitOfWork` the same way it already replaces `ITenantContext`. The real protocol is asserted in `TransactionBehaviorTests` and, against a real database, in `UnitOfWorkTests`. ADR: ADR-0040, ADR-0003, ADR-0033 Co-Authored-By: Claude Opus 5 (1M context) --- .../PersistenceCompositionExtensions.cs | 81 ++++ backend/src/LearnStack.Api/Program.cs | 1 + .../Pipeline/TransactionBehavior.cs | 100 +++-- .../ModuleDbContextRegistration.cs | 93 +++++ .../Persistence/NpgsqlUnitOfWork.cs | 268 ++++++++++++++ .../Persistence/IUnitOfWork.cs | 142 +++++++ .../PersistenceConventionTests.cs | 96 +++++ .../CrossCuttingFoundationHttpTests.cs | 70 ++++ .../Database/UnitOfWorkTests.cs | 346 ++++++++++++++++++ .../Pipeline/TransactionBehaviorTests.cs | 159 ++++++++ .../21-architecture-tests-catalogue.md | 24 +- 11 files changed, 1345 insertions(+), 35 deletions(-) create mode 100644 backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs create mode 100644 backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs create mode 100644 backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs create mode 100644 backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs new file mode 100644 index 00000000..705ebeaf --- /dev/null +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -0,0 +1,81 @@ +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Persistence; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Npgsql; + +namespace LearnStack.Api.Composition; + +/// +/// The persistence half of the composition root: the application data source, +/// the ambient unit of work, and every module DbContext built on it. +/// +/// +/// +/// One data source, and it is the application role's. +/// ConnectionStrings:Default names learnstack_app, which is +/// NOBYPASSRLS and holds USAGE but not CREATE on schema +/// public. ConnectionStrings:Migration is never read here — that +/// credential lives in make migrate and nowhere else, because a runtime +/// that is the table owner is the arrangement FORCE ROW LEVEL SECURITY +/// exists to defeat +/// (Database Standards +/// § Database roles). +/// +/// +/// Resolved lazily, not built eagerly. The data source is a singleton +/// whose factory reads the connection string when something first needs a +/// connection. A deployment with no database configured therefore fails on the +/// first request that touches one, naming the key — rather than at startup, which +/// would make every WebApplicationFactory test carry a database it does +/// not use. +/// +/// +/// ConnectionStrings:PlatformAdmin and +/// ConnectionStrings:OutboxDispatcher are deliberately absent. They are +/// keyed data sources reachable only from PlatformAdminScope and the +/// outbox dispatcher, and they land with their consumers — Packet 7 and Phase +/// 02b — under +/// Platform_DataSource_Resolved_Only_By_PlatformAdminScope. +/// +/// +public static class PersistenceCompositionExtensions +{ + private const string DefaultConnectionName = "Default"; + + public static IServiceCollection AddLearnStackPersistence( + this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.TryAddSingleton(_ => + { + var connectionString = configuration.GetConnectionString(DefaultConnectionName); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + "ConnectionStrings:Default is not configured. It names the learnstack_app " + + "role — the NOBYPASSRLS runtime credential — and is in .env.example. Do " + + "not point it at ConnectionStrings:Migration: that role owns every table, " + + "and a runtime that is the owner is what FORCE ROW LEVEL SECURITY exists " + + "to defeat."); + } + + return new NpgsqlDataSourceBuilder(connectionString).Build(); + }); + + // Scoped: one connection per request, owned by this, shared by every + // context resolved in the scope (ADR-0040). + services.TryAddScoped(); + + // Every module context goes through the helper. A registration that built + // its own connection string would give the context its own connection, + // which never sees SET LOCAL and reads zero rows from every tenant-owned + // table — silently. + services.AddModuleDbContext(); + + return services; + } +} diff --git a/backend/src/LearnStack.Api/Program.cs b/backend/src/LearnStack.Api/Program.cs index d0d2ee84..180373b7 100644 --- a/backend/src/LearnStack.Api/Program.cs +++ b/backend/src/LearnStack.Api/Program.cs @@ -30,6 +30,7 @@ builder.AddLearnStackCrossCuttingFoundation(deploymentMode); builder.Services.AddLearnStackTenancyEdge(builder.Configuration); +builder.Services.AddLearnStackPersistence(builder.Configuration); builder.Services.AddLearnStackRateLimiting(); // The outer half of the body bound, and deliberately NOT the same number. diff --git a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs index 124b2447..d47cec16 100644 --- a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs @@ -1,53 +1,101 @@ +using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; using MediatR; namespace LearnStack.Application.Pipeline; /// /// MediatR pipeline behavior — step 6 of the canonical 8-step order -/// (ADR-0032 § Sub-decision 2). Opens a unit-of-work transaction; commits on -/// a success-Result and rolls back on a fail-Result or any -/// exception that bubbles through. Validation- and authorization-failed -/// requests short-circuit upstream and never open a transaction. +/// (ADR-0032 § Sub-decision 2). Opens the ambient transaction, issues the Row +/// Level Security session variables as its first statement, then commits on a +/// success-Result and rolls back on a fail-Result or any exception +/// that bubbles through. /// /// -/// Phase 02a Packet 3 ships the shell: it delegates to the -/// inner pipeline so the canonical eight-step order can be wired now. Packet 6 -/// replaces the body without changing registration order. -/// -/// /// -/// The replacement opens the transaction through -/// IUnitOfWork.BeginTransactionAsync and sets the tenant context through -/// IUnitOfWork.SetTenantContextAsyncnot through a -/// DbContext.Database.BeginTransactionAsync(). Per +/// Through IUnitOfWork, never a DbContext. Per /// ADR-0040 /// the unit of work owns one connection per scope and every module -/// DbContext enlists on it; a behavior that reached for a context would -/// have to name a module, which is the thing this seam exists to avoid, and a -/// context on its own connection never sees the SET LOCAL and reads zero -/// rows. +/// DbContext enlists on it. A behavior that reached for a context would +/// have to name a module — the thing this seam exists to avoid — and a context +/// on its own connection never sees the SET LOCAL and reads zero rows. +/// TransactionBehavior_Does_Not_Reference_A_Module_Assembly is the guard. +/// +/// +/// No gate. Everything reaching step 6 needs a transaction, because the +/// requests that must not open one have already short-circuited: a validation +/// failure at step 1, an unresolved tenant at step 4, an authorization denial at +/// step 5. A RequiresTransaction(request) predicate would be a fourth +/// exemption defined nowhere. +/// +/// +/// What is not here yet. The MUST-class audit write — +/// IAuditStore.WritePendingAsync(unitOfWork, ct) immediately before +/// COMMIT, per +/// ADR-0033 +/// — belongs on this exact line and lands with IAuditStore in +/// Packet 9, +/// together with the IAuditStateCapture transitions that make the commit +/// the only place durability is claimed. The commit boundary is here now so that +/// the write has somewhere to go. /// /// -public sealed class TransactionBehavior +public sealed class TransactionBehavior( + IUnitOfWork unitOfWork, + ITenantContext tenantContext) : IPipelineBehavior where TRequest : notnull where TResponse : IResultBase { - public Task Handle( + public async Task Handle( TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(next); - // TODO(2026-08-27, @platform): Phase 02a Packet 6 — replace this body per - // ADR-0040: IUnitOfWork.BeginTransactionAsync, then SetTenantContextAsync - // as the first statement inside it, then next(); CommitAsync on a - // success-Result, RollbackAsync on a fail-Result, and rollback + rethrow - // on exception (preserving the rethrow AuditLogBehavior owns one frame - // out). A nested Begin joins the ambient transaction and does not commit. + await unitOfWork.BeginTransactionAsync(cancellationToken); + + // First statement inside the transaction, per ADR-0003 Amendment 3. Until + // Packet 7's TenantResolverMiddleware populates ITenantContext this writes + // the empty string, and that is correct: the policies read + // NULLIF(current_setting(...), '')::uuid, so an unresolved context is a + // NULL predicate and every tenant-owned table returns zero rows. + await unitOfWork.SetTenantContextAsync(tenantContext, cancellationToken); + + try + { + var response = await next(); + + if (response.IsFailure) + { + // A business-rule failure is not an exception (ADR-0032 + // § Sub-decision 4), and it still must not commit: the handler + // may have written before deciding it could not finish. + await unitOfWork.RollbackAsync(CancellationToken.None); + return response; + } + + // TODO(2026-08-28, @platform, phase-02a-packet-9): the MUST-class + // audit write goes here, immediately before the commit — + // await auditStore.WritePendingAsync(unitOfWork, cancellationToken); + // It throws on failure, which reaches the catch below and rolls the + // business write back, which is what ADR-0033 means by fail-closed. + await unitOfWork.CommitAsync(cancellationToken); + return response; + } + catch + { + // CancellationToken.None: the rollback is the cleanup path, and a + // cancelled rollback leaves the transaction open on a connection + // about to go back to the pool. + await unitOfWork.RollbackAsync(CancellationToken.None); - return next(); + // Rethrown, not swallowed: AuditLogBehavior one frame out catches it, + // audits the failure and rethrows through ExceptionDispatchInfo, and + // the L1 IExceptionHandler turns it into Problem Details. + throw; + } } } diff --git a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs new file mode 100644 index 00000000..a5c9b22c --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs @@ -0,0 +1,93 @@ +using System.Collections.ObjectModel; +using LearnStack.SharedKernel.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace LearnStack.Infrastructure.Persistence; + +/// +/// The one sanctioned way to register a module DbContext. +/// +/// +/// +/// AddDbContext<T>(o => o.UseNpgsql(connectionString)) — the EF +/// default — gives the context its own connection, and a context on its own +/// connection never saw SET LOCAL app.tenant_id. Under the corrected Row +/// Level Security policy every read through it returns zero rows, silently +/// (ADR-0040). +/// This helper builds every context on the connection +/// owns and enlists it in the ambient transaction. +/// +/// +/// Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork is the guard, and it +/// reads : a context registered any other way is +/// absent from that set, which is what the rule asserts against. +/// +/// +public static class ModuleDbContextRegistration +{ + private static readonly HashSet Registered = []; + + /// + /// Every context type registered through . + /// + /// + /// Process-wide rather than per-container, because the rule that reads it is + /// a structural assertion about the composition root, and a test that built + /// its own container would otherwise assert about that container instead. + /// + public static IReadOnlyCollection RegisteredContexts => + new ReadOnlyCollection([.. Registered]); + + /// + /// Registers scoped, built on the ambient + /// connection and enlisted in the ambient transaction. + /// + public static IServiceCollection AddModuleDbContext(this IServiceCollection services) + where TContext : DbContext + { + ArgumentNullException.ThrowIfNull(services); + + lock (Registered) + { + Registered.Add(typeof(TContext)); + } + + services.TryAddScoped(provider => + { + var unitOfWork = provider.GetRequiredService(); + + if (unitOfWork.Transaction is null) + { + // Fail loud rather than fail silent. Without a transaction the + // context would still work — it would read through the shared + // connection in autocommit, with no SET LOCAL, and return zero + // rows from every tenant-owned table. That is the exact failure + // ADR-0040 exists to prevent, and it is indistinguishable from + // "there is no data" at the call site. + throw new InvalidOperationException( + $"{typeof(TContext).Name} was resolved outside the ambient transaction. " + + "TransactionBehavior opens it at step 6 of the MediatR pipeline, and the " + + "event transport opens it per delivery; a context resolved before either " + + "reads zero rows from every tenant-owned table because it never saw " + + "SET LOCAL app.tenant_id."); + } + + var options = new DbContextOptionsBuilder() + // The connection, not a connection string. contextOwnsConnection + // is false by this overload's contract, so disposing the context + // does not return the connection to the pool underneath its + // siblings — IUnitOfWork is the sole owner. + .UseNpgsql(unitOfWork.Connection) + .Options; + + var context = (TContext)Activator.CreateInstance(typeof(TContext), options)!; + context.Database.UseTransaction(unitOfWork.Transaction); + + return context; + }); + + return services; + } +} diff --git a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs new file mode 100644 index 00000000..182497d3 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs @@ -0,0 +1,268 @@ +using System.Data.Common; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using Npgsql; + +namespace LearnStack.Infrastructure.Persistence; + +/// +/// The PostgreSQL : one connection from the application +/// data source per scope, and the transaction on it. +/// +/// +/// +/// Registered scoped. It is the sole owner of the connection — every +/// module DbContext is built against it with +/// contextOwnsConnection: false, so disposing a context does not return +/// the connection to the pool underneath its siblings. Disposal order is +/// transaction, then connection, and disposing with a live transaction rolls it +/// back +/// (ADR-0040 +/// § Consequences). +/// +/// +/// Frames, not a boolean. Nesting is tracked as a depth because +/// TransactionBehavior calls CommitAsync directly rather than +/// through the handle, and ADR-0040 § Nesting says a nested frame "never commits, +/// never rolls back". Only a depth counter can make that true of a bare +/// CommitAsync: an inner call resolves its own frame and the outermost one +/// touches the database. +/// +/// +public sealed class NpgsqlUnitOfWork(NpgsqlDataSource dataSource) : IUnitOfWork +{ + private readonly NpgsqlDataSource _dataSource = + dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + + private NpgsqlConnection? _connection; + private DbTransaction? _transaction; + private int _depth; + private bool _rollbackOnly; + private bool _disposed; + + public DbConnection Connection + { + get + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_connection is null) + { + // Synchronous, and reached only by a caller that touches the + // connection before opening a transaction. The ordinary path is + // BeginTransactionAsync, which opens it asynchronously first. + _connection = _dataSource.CreateConnection(); + _connection.Open(); + } + + return _connection; + } + } + + public DbTransaction? Transaction => _transaction; + + public bool HasActiveTransaction => _transaction is not null; + + public async Task BeginTransactionAsync( + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_transaction is null) + { + _connection ??= _dataSource.CreateConnection(); + + if (_connection.State != System.Data.ConnectionState.Open) + { + await _connection.OpenAsync(cancellationToken); + } + + _transaction = await _connection.BeginTransactionAsync(cancellationToken); + _rollbackOnly = false; + } + + return new Frame(this, ++_depth); + } + + public async Task SetTenantContextAsync( + ITenantContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_transaction is null) + { + throw new InvalidOperationException( + "SetTenantContextAsync requires an open transaction: set_config(..., true) is " + + "transaction-local, so outside one the value is discarded before the query it " + + "protects runs. Call BeginTransactionAsync first."); + } + + if (_depth > 1) + { + // A joiner. Re-issuing would let an inner frame retarget the outer + // frame's tenant — the same connection, the same transaction, a + // different tenant for every statement after it. + return; + } + + // Written even when the context is unresolved, and written as the empty + // string. The policies read NULLIF(current_setting(..., true), '')::uuid, + // so '' is NULL is fail-closed — and setting it explicitly is what makes + // a value left behind on a pooled connection by anything session-scoped + // unreachable, rather than merely unlikely. + await ExecuteAsync( + "SELECT set_config('app.tenant_id', @tenant, true), " + + "set_config('app.organization_id', @organization, true)", + cancellationToken, + ("tenant", context.IsResolved ? context.TenantId.ToString() : string.Empty), + ("organization", + context.IsResolved && context.OrganizationId is { } organization + ? organization.ToString() + : string.Empty)); + } + + public async Task CommitAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + EnsureFrameOpen(); + + if (--_depth > 0) + { + // A joiner's commit is not a commit. + return; + } + + if (_rollbackOnly) + { + await RollbackCoreAsync(); + + throw new InvalidOperationException( + "The ambient transaction is marked rollback-only and has been rolled back. " + + "An inner frame failed, so committing here would commit a partial unit."); + } + + var transaction = _transaction!; + _transaction = null; + await transaction.CommitAsync(cancellationToken); + await transaction.DisposeAsync(); + } + + public async Task RollbackAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + EnsureFrameOpen(); + + _rollbackOnly = true; + + if (--_depth > 0) + { + return; + } + + await RollbackCoreAsync(); + } + + public void MarkRollbackOnly() + { + ObjectDisposedException.ThrowIf(_disposed, this); + _rollbackOnly = true; + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + + // A scope that ended with a live transaction has failed: committing here + // would commit work nobody claimed was finished. + if (_transaction is not null) + { + await RollbackCoreAsync(); + } + + if (_connection is not null) + { + await _connection.DisposeAsync(); + _connection = null; + } + } + + private async Task RollbackCoreAsync() + { + var transaction = _transaction; + _transaction = null; + _depth = 0; + + if (transaction is null) + { + return; + } + + // CancellationToken.None: a rollback is the cleanup path, and cancelling + // it would leave the transaction open on a connection about to go back to + // the pool. + await transaction.RollbackAsync(CancellationToken.None); + await transaction.DisposeAsync(); + } + + private void EnsureFrameOpen() + { + if (_depth == 0 || _transaction is null) + { + throw new InvalidOperationException( + "No transaction frame is open. CommitAsync and RollbackAsync resolve a frame " + + "opened by BeginTransactionAsync."); + } + } + + private async Task ExecuteAsync( + string sql, CancellationToken cancellationToken, params (string Name, object Value)[] parameters) + { + await using var command = _connection!.CreateCommand(); + command.CommandText = sql; + command.Transaction = (NpgsqlTransaction?)_transaction; + + foreach (var (name, value) in parameters) + { + command.Parameters.AddWithValue(name, value); + } + + await command.ExecuteNonQueryAsync(cancellationToken); + } + + /// One frame of the ambient transaction. + private sealed class Frame(NpgsqlUnitOfWork unitOfWork, int depth) : IUnitOfWorkScope + { + private bool _resolved; + + public bool IsOwner => depth == 1; + + public Task CompleteAsync(CancellationToken cancellationToken = default) + { + if (_resolved || unitOfWork._depth < depth) + { + // Already resolved, here or by an explicit CommitAsync. + return Task.CompletedTask; + } + + _resolved = true; + return unitOfWork.CommitAsync(cancellationToken); + } + + public async ValueTask DisposeAsync() + { + if (_resolved || unitOfWork._disposed || unitOfWork._depth < depth) + { + return; + } + + _resolved = true; + await unitOfWork.RollbackAsync(CancellationToken.None); + } + } +} diff --git a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs new file mode 100644 index 00000000..ae4cdcc8 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs @@ -0,0 +1,142 @@ +using System.Data.Common; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.SharedKernel.Persistence; + +/// +/// The ambient unit of work: one database connection per scope, and the +/// transaction on it. +/// +/// +/// +/// ADR-0040 +/// makes the connection count a correctness property rather than a performance +/// one. SET LOCAL app.tenant_id is connection- and +/// transaction-local, so a DbContext that opened its own connection never +/// saw that statement, and under the corrected Row Level Security policy every +/// read through it returns zero rows — silently, because a policy that +/// filters everything is indistinguishable from a table with no matching data. +/// +/// +/// Every module DbContext resolved in the scope is built on this +/// connection and enlisted in this transaction; IAuditStore and +/// IOutbox reach the same connection through the same seam. There is no +/// SaveChangesAsync here: contexts save themselves, and the unit of work +/// owns only the transaction boundary. +/// +/// +/// Nesting. An application contract may reach a second handler through +/// ISender, so a second on a live +/// transaction is reachable. The outermost call owns the transaction and is the +/// only one whose commits; an inner call joins, and its +/// commit resolves its own frame and nothing else. An inner +/// — or an explicit +/// — makes the whole unit unable to commit, so a +/// partial unit is never committed by an outer frame that did not notice. +/// +/// +/// One command at a time. One connection means the ambient transaction +/// cannot be used concurrently; a handler that fans out with +/// Task.WhenAll over two module contexts corrupts the protocol. +/// Modules_Do_Not_Parallelize_Over_The_Ambient_Connection is owed for +/// this by Phase 03, with the first module code that could break it. +/// +/// +public interface IUnitOfWork : IAsyncDisposable +{ + /// + /// The ambient connection. Opened on first access, never before. + /// + /// + /// A long-running request holds a pooled connection for its whole life, + /// including across an await on an external provider, which is why it + /// is acquired on first use rather than at scope start. In practice + /// is the first use and opens it + /// asynchronously; reading this property before that opens it synchronously. + /// + DbConnection Connection { get; } + + /// + /// The ambient transaction; null before the first begin and after the + /// terminal call. + /// + DbTransaction? Transaction { get; } + + /// True once a transaction has been opened and not yet resolved. + bool HasActiveTransaction { get; } + + /// + /// Joins the ambient transaction if one is active; otherwise opens it. + /// + /// + /// A handle for the frame this call opened. Completing it resolves that + /// frame; disposing it without completing rolls the unit back, because a + /// frame that ended without an explicit terminal call has failed and + /// committing it would commit work nobody claimed was finished. + /// + Task BeginTransactionAsync(CancellationToken cancellationToken = default); + + /// + /// Issues the Row Level Security session variables as the first statement + /// inside the transaction. + /// + /// + /// It lives here, not in TransactionBehavior, because the statement is + /// SQL and + /// Backend Coding + /// Standards keeps SQL out of the Application layer. A no-op for a + /// joiner: re-issuing it inside the same transaction would let an inner frame + /// silently retarget the outer frame's tenant. + /// + Task SetTenantContextAsync(ITenantContext context, CancellationToken cancellationToken = default); + + /// + /// Resolves the innermost open frame. On the outermost frame this commits — + /// unless the unit is marked rollback-only, in which case it throws rather + /// than committing a partial unit. + /// + Task CommitAsync(CancellationToken cancellationToken = default); + + /// + /// Resolves the innermost open frame by failing it. The unit becomes + /// rollback-only, and the outermost frame performs the actual + /// ROLLBACK. + /// + Task RollbackAsync(CancellationToken cancellationToken = default); + + /// + /// Marks the ambient transaction as unable to commit. Irreversible. + /// + /// + /// An inner Result.Fail that an outer handler deliberately absorbs is + /// not a failure and does not mark it — only an exception, an explicit + /// , or this call does. + /// + void MarkRollbackOnly(); +} + +/// +/// A handle for one frame of the ambient transaction. +/// +/// +/// Returned by so a caller can +/// write await using and have the frame resolved either way. +/// TransactionBehavior does not use it — it calls +/// / +/// explicitly, because which one it calls depends on the Result the +/// handler returned. +/// +public interface IUnitOfWorkScope : IAsyncDisposable +{ + /// + /// true when this frame opened the transaction, and is therefore the + /// one whose completion commits. + /// + bool IsOwner { get; } + + /// + /// Resolves this frame successfully. On the owning frame that is the commit; + /// on a joiner it is a no-op, per ADR-0040 § Nesting. + /// + Task CompleteAsync(CancellationToken cancellationToken = default); +} diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index 3b1586e9..755b1982 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -1,9 +1,14 @@ using System.Xml.Linq; using FluentAssertions; +using LearnStack.Api.Composition; +using LearnStack.Application.Pipeline; +using LearnStack.Infrastructure.Persistence; using LearnStack.Modules.Tenancy.Infrastructure.Persistence; using LearnStack.SharedKernel.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace LearnStack.Tests.Architecture; @@ -103,6 +108,97 @@ public void Migration_Startup_Project_References_EntityFrameworkCore_Design() + "dotnet ef resolves the design-time package from there"); } + [Fact] + public void Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork() + { + // Two halves, because either alone leaves the hole open. + // + // The registration half: the composition root's own persistence + // registration is run, and every DbContext service in it must be one the + // shared helper registered. A context registered any other way is absent + // from ModuleDbContextRegistration.RegisteredContexts. + var services = new ServiceCollection(); + services.AddLearnStackPersistence(new ConfigurationBuilder().Build()); + + var contexts = services + .Where(descriptor => typeof(DbContext).IsAssignableFrom(descriptor.ServiceType)) + .ToList(); + + contexts.Should().NotBeEmpty("TenancyDbContext is registered and is the first consumer"); + + contexts.Should().OnlyContain( + descriptor => ModuleDbContextRegistration.RegisteredContexts.Contains(descriptor.ServiceType), + "every DbContext registration goes through AddModuleDbContext"); + + contexts.Should().OnlyContain( + descriptor => descriptor.Lifetime == ServiceLifetime.Scoped + && descriptor.ImplementationFactory != null, + "a context is built per scope, from the connection IUnitOfWork owns — " + + "a type registration would let EF open its own"); + + // The call-site half: a context on its own connection never saw + // SET LOCAL, so every read through it returns zero rows under the + // corrected policy — silently. `UseNpgsql` with a connection string is how + // that happens, and there are exactly three files under backend/src that + // may configure a provider at all: the two design-time factories, where a + // connection string is the point, and the shared helper, which passes a + // connection rather than a string. A fourth is a new decision. + var callSites = Directory + .EnumerateFiles(RepositoryPaths.BackendSrc(), "*.cs", SearchOption.AllDirectories) + .Where(file => !file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", + StringComparison.Ordinal) + && !file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", + StringComparison.Ordinal)) + .Where(file => StripComments(File.ReadAllText(file)) + .Contains("UseNpgsql", StringComparison.Ordinal) + || StripComments(File.ReadAllText(file)) + .Contains("AddDbContext", StringComparison.Ordinal)) + .Select(Path.GetFileName) + .Order(StringComparer.Ordinal) + .ToList(); + + callSites.Should().BeEquivalentTo( + [ + "ModuleDbContextRegistration.cs", + "PlatformDbContextFactory.cs", + "TenancyDbContextFactory.cs", + ]); + } + + [Fact] + public void TransactionBehavior_Does_Not_Reference_A_Module_Assembly() + { + // The seam exists so that the behavior owning the commit boundary never + // has to name a module. Two assertions: the assembly takes no build-time + // reference to one, and the behavior's own surface names IUnitOfWork and + // no DbContext. + var application = typeof(TransactionBehavior<,>).Assembly; + + application.GetReferencedAssemblies() + .Select(reference => reference.Name) + .Should().NotContain(name => name!.StartsWith("LearnStack.Modules.", StringComparison.Ordinal), + "LearnStack.Application is generic over every module and references none"); + + var constructor = typeof(TransactionBehavior<,>).GetConstructors().Single(); + + constructor.GetParameters().Select(parameter => parameter.ParameterType) + .Should().Contain(typeof(IUnitOfWork)) + .And.NotContain(parameter => typeof(DbContext).IsAssignableFrom(parameter)); + } + + /// + /// Source with its comments removed. + /// + /// + /// Every file the scan above touches argues in prose about the very call it + /// is forbidden to make, so scanning raw text would fail on the documentation + /// that explains the rule. + /// + private static string StripComments(string source) => + System.Text.RegularExpressions.Regex.Replace( + source, @"//[^\n]*|/\*.*?\*/", string.Empty, + System.Text.RegularExpressions.RegexOptions.Singleline); + [Fact] public void Migrate_Target_Covers_Every_Migration_Chain() { diff --git a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs index bae5c890..82b39d86 100644 --- a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs @@ -11,6 +11,7 @@ using LearnStack.Api.Common; using LearnStack.SharedKernel.Errors; using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Results; using FluentValidation; using LearnStack.SharedKernel.Identifiers; @@ -234,10 +235,79 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) // handler. services.RemoveAll(); services.AddScoped(_ => TestResolvedTenantContext.Instance); + + // TransactionBehavior opens a real transaction on every request that + // reaches step 6 — ADR-0040 § Decision has no gate, deliberately, + // because a read needs the SET LOCAL as much as a write does. This + // host has no database: it is a WebApplicationFactory in the non-Docker + // CI job, and what it tests is validation and the Problem Details + // shape. So the seam is replaced rather than satisfied. The real + // protocol is asserted in TransactionBehaviorTests (the call order) + // and UnitOfWorkTests (against a real PostgreSQL). + services.RemoveAll(); + services.AddScoped(); }); } } +/// +/// An for a host with no database. +/// +/// +/// Every member is a no-op except , which throws: a test +/// that reached for the connection would be a test that needs a database, and +/// should say so by carrying the Docker trait instead of silently getting null. +/// +internal sealed class NoDatabaseUnitOfWork : IUnitOfWork +{ + public System.Data.Common.DbConnection Connection => + throw new NotSupportedException( + "This host has no database. A test that needs one belongs in the " + + "Docker-trait suite (see RequiresDocker)."); + + public System.Data.Common.DbTransaction? Transaction => null; + + public bool HasActiveTransaction { get; private set; } + + public Task BeginTransactionAsync(CancellationToken cancellationToken = default) + { + HasActiveTransaction = true; + return Task.FromResult(new Frame(this)); + } + + public Task SetTenantContextAsync( + ITenantContext context, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task CommitAsync(CancellationToken cancellationToken = default) + { + HasActiveTransaction = false; + return Task.CompletedTask; + } + + public Task RollbackAsync(CancellationToken cancellationToken = default) + { + HasActiveTransaction = false; + return Task.CompletedTask; + } + + public void MarkRollbackOnly() + { + // Nothing to mark: there is no transaction to refuse to commit. + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + private sealed class Frame(NoDatabaseUnitOfWork unitOfWork) : IUnitOfWorkScope + { + public bool IsOwner => true; + + public Task CompleteAsync(CancellationToken cancellationToken = default) => + unitOfWork.CommitAsync(cancellationToken); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} + internal sealed class TestResolvedTenantContext : ITenantContext { public static TestResolvedTenantContext Instance { get; } = new(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs new file mode 100644 index 00000000..1a7926e4 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -0,0 +1,346 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// The ambient unit of work against a real database: the connection it owns, the +/// session variables it issues, the context it enlists, and what a nested frame +/// can and cannot do. +/// +/// +/// +/// The container is shared with the schema cases, so every test here either rolls +/// back or removes what it committed. The one that commits does so on +/// outbox_messages and deletes the row through learnstack_platform +/// in a finally, because a row left behind changes a count another case +/// asserts. +/// +/// +/// What this cannot prove. +/// ADR-0040 +/// § What Packet 6 can and cannot prove says the multi-context property needs a +/// second module context, which Phase 03 ships. What is provable now is the +/// half that makes it work: a context resolved through the shared helper reads +/// rows written on the ambient connection inside the same uncommitted +/// transaction, which is only true if it enlisted rather than opening its own. +/// +/// +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class UnitOfWorkTests +{ + private readonly SchemaFixture _schema; + + public UnitOfWorkTests(SchemaFixture schema) => _schema = schema; + + [Fact] + public async Task A_resolved_context_becomes_the_session_variables() + { + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync( + Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + + (await ReadAsync(unitOfWork, "SELECT current_setting('app.tenant_id', true)")) + .Should().Be(SchemaFixture.TenantA.ToString()); + (await ReadAsync(unitOfWork, "SELECT current_setting('app.organization_id', true)")) + .Should().Be(SchemaFixture.OrgA1.ToString()); + + await unitOfWork.RollbackAsync(); + } + + [Fact] + public async Task An_unresolved_context_leaves_every_tenant_owned_table_empty() + { + // Between Packet 6 and Packet 7 every request runs against + // UnresolvedTenantContext, and this is what that costs: the GUCs are set + // to the empty string, NULLIF turns them into NULL, and a NULL predicate + // is false for USING and WITH CHECK alike. Fail-closed by construction, + // not by a filter that does not exist yet. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync(UnresolvedTenantContext.Instance); + + (await ReadAsync(unitOfWork, "SELECT current_setting('app.tenant_id', true)")) + .Should().BeEmpty(); + + var context = scope.ServiceProvider.GetRequiredService(); + (await context.Tenants.CountAsync()).Should().Be(0); + (await context.Organizations.CountAsync()).Should().Be(0); + + await unitOfWork.RollbackAsync(); + } + + [Fact] + public async Task A_module_context_enlists_in_the_ambient_transaction() + { + // The property the shared registration helper exists for. The row is + // written on the raw ambient connection and never committed; a context + // that had opened its own connection could not see it, and a context that + // saw the connection but not the transaction could not either. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync(Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + + await ExecuteAsync(unitOfWork, + """ + INSERT INTO organizations + (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) + VALUES (uuidv7(), @tenant, 'enlisted', 'Enlisted', 'Active', now(), @actor, 0) + """, + ("tenant", SchemaFixture.TenantA), ("actor", SchemaFixture.Actor)); + + var context = scope.ServiceProvider.GetRequiredService(); + + (await context.Organizations.CountAsync()).Should().Be(3, + "the two seeded organizations plus the uncommitted one on this transaction"); + + await unitOfWork.RollbackAsync(); + + // And the rollback is real: a fresh scope sees the seeded two. + await using var after = provider.CreateAsyncScope(); + var afterUnitOfWork = after.ServiceProvider.GetRequiredService(); + await afterUnitOfWork.BeginTransactionAsync(); + await afterUnitOfWork.SetTenantContextAsync(Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + + (await after.ServiceProvider.GetRequiredService() + .Organizations.CountAsync()).Should().Be(2); + + await afterUnitOfWork.RollbackAsync(); + } + + [Fact] + public async Task A_context_resolved_outside_the_transaction_fails_loudly() + { + // The alternative is a context that reads zero rows from every + // tenant-owned table and cannot say why — indistinguishable from "there + // is no data" at the call site. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + + var resolve = () => scope.ServiceProvider.GetRequiredService(); + + resolve.Should().Throw() + .WithMessage("*outside the ambient transaction*"); + } + + [Fact] + public async Task A_nested_frame_joins_and_its_commit_is_not_a_commit() + { + // An application contract reaching a second handler through ISender is + // how this happens. The inner frame's commit must not make the outer + // frame's work durable. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + var outer = await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync(Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + var transaction = unitOfWork.Transaction; + + var inner = await unitOfWork.BeginTransactionAsync(); + inner.IsOwner.Should().BeFalse(); + outer.IsOwner.Should().BeTrue(); + unitOfWork.Transaction.Should().BeSameAs(transaction, "a nested begin joins, it does not nest"); + + await inner.CompleteAsync(); + + unitOfWork.HasActiveTransaction.Should().BeTrue( + "the inner frame resolved; the transaction is still the outer frame's"); + + await unitOfWork.RollbackAsync(); + unitOfWork.HasActiveTransaction.Should().BeFalse(); + } + + [Fact] + public async Task A_nested_failure_makes_the_outer_commit_impossible() + { + // ADR-0040 § Nesting: an inner failure marks the transaction + // rollback-only, and the outer commit throws rather than committing a + // partial unit. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync(Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + + await unitOfWork.BeginTransactionAsync(); + await unitOfWork.RollbackAsync(); + + var commit = async () => await unitOfWork.CommitAsync(); + + (await commit.Should().ThrowAsync()) + .WithMessage("*rollback-only*"); + unitOfWork.HasActiveTransaction.Should().BeFalse("the failed unit was rolled back"); + } + + [Fact] + public async Task Disposing_with_a_live_transaction_rolls_it_back() + { + await using var provider = BuildProvider(); + Guid id; + + await using (var scope = provider.CreateAsyncScope()) + { + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync(Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + + id = Guid.CreateVersion7(); + await ExecuteAsync(unitOfWork, + """ + INSERT INTO organizations + (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) + VALUES (@id, @tenant, 'abandoned', 'Abandoned', 'Active', now(), @actor, 0) + """, + ("id", id), ("tenant", SchemaFixture.TenantA), ("actor", SchemaFixture.Actor)); + + // No commit, no rollback: the scope simply ends. Committing here would + // commit work nobody claimed was finished. + } + + await using var platform = await PostgresFixture.OpenAsync(_schema.Postgres.PlatformConnectionString); + await using var check = new NpgsqlCommand( + "SELECT count(*) FROM organizations WHERE id = @id", (NpgsqlConnection)platform); + check.Parameters.AddWithValue("id", id); + + (await check.ExecuteScalarAsync()).Should().Be(0L); + } + + [Fact] + public async Task A_committed_unit_survives_the_scope() + { + // The other half of the previous case, and the only test here that + // commits — on outbox_messages, and cleaned up in a finally, because the + // fixture's row counts are what the schema cases assert. + await using var provider = BuildProvider(); + var correlation = $"00-uow-{Guid.CreateVersion7():N}"; + + try + { + await using (var scope = provider.CreateAsyncScope()) + { + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync( + Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + + await ExecuteAsync(unitOfWork, + """ + INSERT INTO outbox_messages + (tenant_id, correlation_id, type, topic, partition_key, payload) + VALUES (@tenant, @correlation, 'T', 'learnstack.tenancy.tenant', 'k', '{}') + """, + ("tenant", SchemaFixture.TenantA), ("correlation", correlation)); + + await unitOfWork.CommitAsync(); + unitOfWork.HasActiveTransaction.Should().BeFalse(); + } + + (await CountOutboxAsync(correlation)).Should().Be(1L); + } + finally + { + await using var platform = await PostgresFixture.OpenAsync( + _schema.Postgres.PlatformConnectionString); + await using var cleanup = new NpgsqlCommand( + "DELETE FROM outbox_messages WHERE correlation_id = @correlation", + (NpgsqlConnection)platform); + cleanup.Parameters.AddWithValue("correlation", correlation); + await cleanup.ExecuteNonQueryAsync(); + } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private ServiceProvider BuildProvider() + { + // The real registration path: the shared helper, the real + // NpgsqlUnitOfWork, and the application role's data source. Only the + // connection string differs from the composition root, because the + // fixture's container is not the one appsettings names. + var services = new ServiceCollection(); + services.AddSingleton(NpgsqlDataSource.Create(_schema.Postgres.AppConnectionString)); + services.AddScoped(); + services.AddModuleDbContext(); + + return services.BuildServiceProvider(); + } + + private async Task CountOutboxAsync(string correlation) + { + await using var platform = await PostgresFixture.OpenAsync( + _schema.Postgres.PlatformConnectionString); + await using var command = new NpgsqlCommand( + "SELECT count(*) FROM outbox_messages WHERE correlation_id = @correlation", + (NpgsqlConnection)platform); + command.Parameters.AddWithValue("correlation", correlation); + + return (long)(await command.ExecuteScalarAsync())!; + } + + private static async Task ReadAsync(IUnitOfWork unitOfWork, string sql) + { + await using var command = unitOfWork.Connection.CreateCommand(); + command.CommandText = sql; + command.Transaction = unitOfWork.Transaction; + + return (await command.ExecuteScalarAsync()) as string ?? string.Empty; + } + + private static async Task ExecuteAsync( + IUnitOfWork unitOfWork, string sql, params (string Name, object Value)[] parameters) + { + await using var command = (NpgsqlCommand)unitOfWork.Connection.CreateCommand(); + command.CommandText = sql; + command.Transaction = (NpgsqlTransaction?)unitOfWork.Transaction; + + foreach (var (name, value) in parameters) + { + command.Parameters.AddWithValue(name, value); + } + + await command.ExecuteNonQueryAsync(); + } + + private static StubTenantContext Resolved(Guid tenant, Guid organization) => + new(tenant, organization); + + /// + /// A resolved context, standing in for what Packet 7's + /// TenantResolverMiddleware will populate. + /// + private sealed class StubTenantContext(Guid tenant, Guid organization) : ITenantContext + { + public bool IsResolved => true; + + public Guid TenantId => tenant; + + public Guid? OrganizationId => organization; + + public UserId? UserId => null; + + public string? CorrelationId => null; + + public string? ModuleName => "tenancy"; + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs new file mode 100644 index 00000000..701b18a7 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs @@ -0,0 +1,159 @@ +using System.Data.Common; +using FluentAssertions; +using LearnStack.Application.Pipeline; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; +using Xunit; + +namespace LearnStack.Tests.Unit.Application.Pipeline; + +/// +/// TransactionBehavior — step 6. The commit boundary: what it calls, in +/// what order, and which outcome resolves the transaction which way. +/// +/// +/// Against a recording IUnitOfWork rather than a database, because the +/// question here is the protocol. Whether the protocol produces the right rows in +/// PostgreSQL is UnitOfWorkTests, which runs against a real one. +/// +public sealed class TransactionBehaviorTests +{ + public sealed record DummyCommand : IRequest>; + + [Fact] + public async Task Opens_The_Transaction_Then_Sets_The_Session_Variables_Then_Runs_The_Handler() + { + // The order is the assertion. SET LOCAL is transaction-local, so issuing + // it before the BEGIN discards it; issuing it after the handler protects + // nothing the handler did. + var unitOfWork = new RecordingUnitOfWork(); + var behavior = Build(unitOfWork); + + var result = await behavior.Handle( + new DummyCommand(), () => Next(unitOfWork, Result.Ok("ok")), default); + + result.IsSuccess.Should().BeTrue(); + unitOfWork.Calls.Should().Equal("begin", "set-tenant", "handler", "commit"); + } + + [Fact] + public async Task Rolls_Back_A_Failure_Result() + { + // A business-rule violation is a fail-Result, not an exception + // (ADR-0032 § Sub-decision 4) — and it must still not commit: the handler + // may have written before deciding it could not finish. + var unitOfWork = new RecordingUnitOfWork(); + var behavior = Build(unitOfWork); + + var failure = Result.FailFor>( + new Error(new LocalizedMessage("lockey_business_rule_violation"))); + + var result = await behavior.Handle( + new DummyCommand(), () => Next(unitOfWork, failure), default); + + result.IsFailure.Should().BeTrue(); + unitOfWork.Calls.Should().Equal("begin", "set-tenant", "handler", "rollback"); + } + + [Fact] + public async Task Rolls_Back_And_Rethrows_An_Exception() + { + // Rethrown rather than converted: AuditLogBehavior one frame out catches + // it, audits the failure and rethrows through ExceptionDispatchInfo, and + // the L1 IExceptionHandler turns it into Problem Details. + var unitOfWork = new RecordingUnitOfWork(); + var behavior = Build(unitOfWork); + + var act = async () => await behavior.Handle( + new DummyCommand(), + () => + { + unitOfWork.Calls.Add("handler"); + throw new InvalidOperationException("handler blew up"); + }, + default); + + (await act.Should().ThrowAsync()) + .WithMessage("handler blew up"); + unitOfWork.Calls.Should().Equal("begin", "set-tenant", "handler", "rollback"); + } + + [Fact] + public async Task Passes_The_Ambient_Tenant_Context_Through() + { + // Not a context of its own. Whatever the resolution stage populated is + // what reaches SET LOCAL — which, until Packet 7, is + // UnresolvedTenantContext, and that is correct and fail-closed. + var unitOfWork = new RecordingUnitOfWork(); + var behavior = Build(unitOfWork); + + await behavior.Handle(new DummyCommand(), () => Next(unitOfWork, Result.Ok("ok")), default); + + unitOfWork.TenantContext.Should().BeSameAs(UnresolvedTenantContext.Instance); + } + + private static TransactionBehavior> Build(IUnitOfWork unitOfWork) => + new(unitOfWork, UnresolvedTenantContext.Instance); + + private static Task> Next(RecordingUnitOfWork unitOfWork, Result result) + { + unitOfWork.Calls.Add("handler"); + return Task.FromResult(result); + } + + /// An that records the protocol. + private sealed class RecordingUnitOfWork : IUnitOfWork, IUnitOfWorkScope + { + public List Calls { get; } = []; + + public ITenantContext? TenantContext { get; private set; } + + public DbConnection Connection => + throw new NotSupportedException("the behavior must never reach for the connection"); + + public DbTransaction? Transaction => null; + + public bool HasActiveTransaction { get; private set; } + + public bool IsOwner => true; + + public Task BeginTransactionAsync(CancellationToken cancellationToken = default) + { + Calls.Add("begin"); + HasActiveTransaction = true; + return Task.FromResult(this); + } + + public Task SetTenantContextAsync( + ITenantContext context, CancellationToken cancellationToken = default) + { + Calls.Add("set-tenant"); + TenantContext = context; + return Task.CompletedTask; + } + + public Task CommitAsync(CancellationToken cancellationToken = default) + { + Calls.Add("commit"); + HasActiveTransaction = false; + return Task.CompletedTask; + } + + public Task RollbackAsync(CancellationToken cancellationToken = default) + { + Calls.Add("rollback"); + HasActiveTransaction = false; + return Task.CompletedTask; + } + + public Task CompleteAsync(CancellationToken cancellationToken = default) => + CommitAsync(cancellationToken); + + public void MarkRollbackOnly() => Calls.Add("mark-rollback-only"); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 8d07485d..65acc6ea 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -722,22 +722,28 @@ rules that need a second `DbContext` are owed by Phase 03. #### `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork` -- **Asserts:** no `DbContext` registration configures its own connection string or - calls `UseNpgsql(string)`; every one is built through the shared registration - helper against the connection `IUnitOfWork` owns. A context on its own connection - never saw `SET LOCAL`, so every read through it returns zero rows under the - corrected policy — silently. +- **Asserts:** two halves. The composition root's persistence registration is run, + and every `DbContext` service in it is one `AddModuleDbContext` registered — + scoped, from an implementation factory, never a type registration EF could give + its own connection. And under `backend/src`, exactly three files may mention + `UseNpgsql` or `AddDbContext` at all: the two design-time factories, where a + connection string is the point, and the shared helper, which passes a + *connection*. A fourth is a new decision. A context on its own connection never + saw `SET LOCAL`, so every read through it returns zero rows under the corrected + policy — silently. - **Source:** ADR-0040; [05-database.md § Forbidden](05-database.md). -- **Type:** xUnit + DI registration inspection. **Kind:** structural. -- **Status:** **Registered.** **Phase:** 02a Packet 6. +- **Type:** xUnit + DI registration inspection and a source scan. **Kind:** structural. +- **Status:** **Implemented** (Packet 6 step 6, + `LearnStack.Tests.Architecture`, `PersistenceConventionTests`). #### `TransactionBehavior_Does_Not_Reference_A_Module_Assembly` - **Asserts:** `TransactionBehavior` names `IUnitOfWork` and no `DbContext`, and `LearnStack.Application` takes no build-time reference to any module assembly. - **Source:** ADR-0040; ADR-0033. -- **Type:** NetArchTest. **Kind:** structural. -- **Status:** **Registered.** **Phase:** 02a Packet 6. +- **Type:** xUnit + assembly-reference and constructor inspection. **Kind:** structural. +- **Status:** **Implemented** (Packet 6 step 6, + `LearnStack.Tests.Architecture`, `PersistenceConventionTests`). #### `Modules_Do_Not_Parallelize_Over_The_Ambient_Connection` From af5fcacdf6e44c600514579321b3584c540c47a6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 14:54:37 +0300 Subject: [PATCH 19/35] fix(kernel): close the Step 6 Opus round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty confirmed findings, three of them blockers, and all three in the same place: what happens when the transaction boundary itself fails. **A commit-time exception was destroyed.** `CommitAsync` resolved the frame before the `COMMIT` round trip, so when the round trip threw there was no frame left, and the behavior's catch called `RollbackAsync`, which threw "No transaction frame is open" over the top of it — no inner exception, no SQLSTATE, no clue. Worse than the lost diagnostic: the replacement is not an `OperationCanceledException`, so a client disconnecting mid-commit was audited as a failure, captured by `IErrorTrackingProvider` and answered `500` instead of `499`, inverting three separate ADR-0032 behaviours at once. The commit now sits outside the catch behind a `when (!committing)` filter — which is what the corpus's own reference body in 31-audit-subsystem.md does with `stateCapture` — a faulted commit disposes its transaction in a `finally` and is left as ADR-0033's `Indeterminate` rather than rolled back, and `RollbackAsync` on a unit with nothing to resolve is a no-op, because cleanup must never throw over the exception it is cleaning up after. **An absorbed inner `Result.Fail` poisoned the whole unit.** ADR-0040 § Nesting decides the opposite in as many words — an inner failure the outer handler absorbs is not a failure of the unit — and `RollbackAsync` set the rollback-only flag before the joiner check, so the outer handler's own committed work was thrown away and it got an exception in place of its success. Measured through the real behavior against a real database. The mark now belongs to the outermost frame and to `MarkRollbackOnly`, which is what the exception path calls explicitly; that is the one cause § Nesting names which a terminal call cannot tell apart on its own. **`ConnectionStrings:Default` was accepted whatever role it named.** Two paragraphs of remarks argued for `learnstack_app` and the factory then built a data source from anything. Point it at either `BYPASSRLS` role — they sit two and three lines away in `.env.example` — and every policy in the database goes inert, turning Packet 6's fail-closed state from "no rows" into "every tenant's rows". Two checks now, because they catch different mistakes: the name, symmetric with the guard `make migrate` has had since step 3; and one round trip per physical connection asking the server `rolbypassrls OR rolsuper`, which catches what a name cannot — `learnstack_app` itself granted the bypass, or a superuser, which bypasses row security with `rolbypassrls = false`. The second is measured by granting the bypass for real and reverting it. Beyond the three: a leaked nested frame turned the outer commit into a silent no-op, so `TransactionBehavior` now resolves through the `IUnitOfWorkScope` handle, which knows its own depth and refuses to complete out of order; `MarkRollbackOnly` is sticky for the life of the unit, because the interface says "irreversible" and a poison a later `BEGIN` clears is not; module contexts get the application service provider, without which every EF Core log category was silent on a seam whose premise is that a misconfigured context fails invisibly; a malformed connection string names its key instead of throwing out of `System.Data.Common`; and `RegisteredContexts` enumerates inside its lock. The tests could not have caught either code blocker, and that is its own finding: the fake unit of work modelled neither nesting depth nor a failing terminal call. It now models both, and the two blockers have cases that go red without their fixes — as do the leaked frame, the sticky mark, and the bypass role. The unresolved-context sweep reads all eight mapped sets instead of two. ADR-0040 Amendment 2 records the handle's shape as shipped — `CompleteAsync` / `FailAsync` / `IsOwner`, resolving innermost-first — since § Decision left `IUnitOfWorkScope` at one sentence, and 31-audit-subsystem.md no longer calls the shipped behavior a shell. ADR: ADR-0040 (Amendment 2), ADR-0033, ADR-0032, ADR-0003 Co-Authored-By: Claude Opus 5 (1M context) --- .../PersistenceCompositionExtensions.cs | 138 +++++++++-- .../LearnStack.Api/Properties/AssemblyInfo.cs | 5 + .../Pipeline/TransactionBehavior.cs | 57 ++++- .../ModuleDbContextRegistration.cs | 23 +- .../Persistence/NpgsqlUnitOfWork.cs | 217 +++++++++++++---- .../Persistence/IUnitOfWork.cs | 96 ++++++-- .../PersistenceConventionTests.cs | 29 ++- .../CrossCuttingFoundationHttpTests.cs | 3 + .../Database/PostgresFixture.cs | 24 ++ .../Database/UnitOfWorkTests.cs | 228 +++++++++++++++++- .../ApplicationDataSourceGuardTests.cs | 116 +++++++++ .../Pipeline/TransactionBehaviorTests.cs | 223 +++++++++++++++-- docs/architecture/31-audit-subsystem.md | 18 +- docs/decisions/0040-ambient-unit-of-work.md | 54 +++++ .../21-architecture-tests-catalogue.md | 8 +- 15 files changed, 1097 insertions(+), 142 deletions(-) create mode 100644 backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 705ebeaf..47312276 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -12,7 +12,7 @@ namespace LearnStack.Api.Composition; /// /// /// -/// One data source, and it is the application role's. +/// One data source, and it must be the application role's. /// ConnectionStrings:Default names learnstack_app, which is /// NOBYPASSRLS and holds USAGE but not CREATE on schema /// public. ConnectionStrings:Migration is never read here — that @@ -23,12 +23,25 @@ namespace LearnStack.Api.Composition; /// § Database roles). /// /// +/// That is checked, not asserted. Two checks, because they fail on +/// different mistakes. The name check refuses a value whose Username is +/// not learnstack_app — the symmetric guard to the one make migrate +/// already performs on the migration credential, and the one that catches the +/// likely operator error of pasting the PlatformAdmin row, which sits two +/// lines away in .env.example. The physical-connection check asks the +/// server whether the role it actually connected as bypasses row security, which +/// catches what a name cannot: learnstack_app itself granted +/// BYPASSRLS, or a superuser, which bypasses row security with +/// rolbypassrls = false. Either mistake makes every policy in the database +/// inert, and Packet 6's fail-closed state — an unresolved tenant context, so +/// app.tenant_id = '' — turns from "no rows" into "every tenant's rows". +/// +/// /// Resolved lazily, not built eagerly. The data source is a singleton -/// whose factory reads the connection string when something first needs a -/// connection. A deployment with no database configured therefore fails on the -/// first request that touches one, naming the key — rather than at startup, which -/// would make every WebApplicationFactory test carry a database it does -/// not use. +/// whose factory runs when something first needs a connection. A deployment with +/// no database configured therefore fails on the first request that touches one, +/// naming the key — rather than at startup, which would make every +/// WebApplicationFactory test carry a database it does not use. /// /// /// ConnectionStrings:PlatformAdmin and @@ -43,6 +56,9 @@ public static class PersistenceCompositionExtensions { private const string DefaultConnectionName = "Default"; + /// The one role a runtime process may connect as. + internal const string RuntimeRole = "learnstack_app"; + public static IServiceCollection AddLearnStackPersistence( this IServiceCollection services, IConfiguration configuration) { @@ -50,21 +66,7 @@ public static IServiceCollection AddLearnStackPersistence( ArgumentNullException.ThrowIfNull(configuration); services.TryAddSingleton(_ => - { - var connectionString = configuration.GetConnectionString(DefaultConnectionName); - - if (string.IsNullOrWhiteSpace(connectionString)) - { - throw new InvalidOperationException( - "ConnectionStrings:Default is not configured. It names the learnstack_app " - + "role — the NOBYPASSRLS runtime credential — and is in .env.example. Do " - + "not point it at ConnectionStrings:Migration: that role owns every table, " - + "and a runtime that is the owner is what FORCE ROW LEVEL SECURITY exists " - + "to defeat."); - } - - return new NpgsqlDataSourceBuilder(connectionString).Build(); - }); + BuildApplicationDataSource(configuration.GetConnectionString(DefaultConnectionName))); // Scoped: one connection per request, owned by this, shared by every // context resolved in the scope (ADR-0040). @@ -78,4 +80,98 @@ public static IServiceCollection AddLearnStackPersistence( return services; } + + /// + /// Validates ConnectionStrings:Default and builds the application data + /// source from it. + /// + /// + /// Internal rather than private so the guard can be tested for what it + /// refuses. Every message redacts the password: this is the one place a + /// runtime credential is read, and an error that echoed it would put it in + /// every log that captured the startup failure. + /// + internal static NpgsqlDataSource BuildApplicationDataSource(string? connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + "ConnectionStrings:Default is not configured. It names the learnstack_app " + + "role — the NOBYPASSRLS runtime credential — and is in .env.example. Do " + + "not point it at ConnectionStrings:Migration: that role owns every table, " + + "and a runtime that is the owner is what FORCE ROW LEVEL SECURITY exists " + + "to defeat."); + } + + NpgsqlConnectionStringBuilder parsed; + + try + { + parsed = new NpgsqlConnectionStringBuilder(connectionString); + } + catch (Exception exception) when (exception is ArgumentException or FormatException) + { + // Npgsql's own message names neither the key nor the file. An + // operator who pasted a URI-style DSN — the form DATABASE_URL carries + // on several hosts — otherwise gets a bare ArgumentException out of + // System.Data.Common. + throw new InvalidOperationException( + $"ConnectionStrings:Default is not a valid connection string: {Redact(connectionString)}. " + + "The expected form is a semicolon-separated key/value list — Host, Port, " + + "Database, Username, Password — not a URI. See .env.example.", + exception); + } + + if (!string.Equals(parsed.Username, RuntimeRole, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"ConnectionStrings:Default names Username='{parsed.Username}', not {RuntimeRole}: " + + $"{Redact(connectionString)}. A runtime process connects as the NOBYPASSRLS " + + "application role and nothing else. learnstack_migration owns every table, and " + + "learnstack_platform and learnstack_outbox_admin hold BYPASSRLS — with any of " + + "them here every Row Level Security policy in the database is inert, and the " + + "unresolved-tenant state that returns no rows returns every tenant's instead. " + + "EnterPlatformAdminScope is the only sanctioned path to a bypass credential."); + } + + var builder = new NpgsqlDataSourceBuilder(connectionString); + + // Asked of the server, once per physical connection, because the name is + // not the privilege: learnstack_app could have been granted BYPASSRLS, and + // a superuser bypasses row security with rolbypassrls = false — which is + // why rolsuper is in the predicate. + builder.UsePhysicalConnectionInitializer( + connection => RefuseBypassRole(connection, async: false).GetAwaiter().GetResult(), + connection => RefuseBypassRole(connection, async: true)); + + return builder.Build(); + } + + private static async Task RefuseBypassRole(NpgsqlConnection connection, bool async) + { + await using var command = connection.CreateCommand(); + command.CommandText = + "SELECT rolbypassrls OR rolsuper FROM pg_roles WHERE rolname = current_user"; + + var bypasses = async + ? await command.ExecuteScalarAsync() + : command.ExecuteScalar(); + + if (bypasses is true) + { + throw new InvalidOperationException( + $"The runtime connected as a role that bypasses Row Level Security " + + $"(rolbypassrls or rolsuper). Every policy in the database is then inert and " + + "every query crosses every tenant boundary. Check ConnectionStrings:Default " + + "and the grants on the role it names; EnterPlatformAdminScope is the only " + + "sanctioned path to a bypass credential."); + } + } + + /// The connection string with its password replaced. + private static string Redact(string connectionString) => + System.Text.RegularExpressions.Regex.Replace( + connectionString, + "(?i)(password\\s*=)[^;]*", + "$1***"); } diff --git a/backend/src/LearnStack.Api/Properties/AssemblyInfo.cs b/backend/src/LearnStack.Api/Properties/AssemblyInfo.cs index 8e38b67d..0af782b4 100644 --- a/backend/src/LearnStack.Api/Properties/AssemblyInfo.cs +++ b/backend/src/LearnStack.Api/Properties/AssemblyInfo.cs @@ -5,3 +5,8 @@ // to module code. [assembly: InternalsVisibleTo("LearnStack.Tests.Unit")] [assembly: InternalsVisibleTo("LearnStack.Tests.Architecture")] + +// The composition root's credential guard is asserted for what it refuses in the +// unit suite, and for what the SERVER refuses — a runtime role granted BYPASSRLS, +// which no connection string can reveal — against a real cluster here. +[assembly: InternalsVisibleTo("LearnStack.Tests.Integration")] diff --git a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs index d47cec16..d344f5c4 100644 --- a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs @@ -30,11 +30,31 @@ namespace LearnStack.Application.Pipeline; /// exemption defined nowhere. /// /// +/// The commit is outside the catch, deliberately. The filter +/// when (!committing) is what stops the cleanup path from running after a +/// faulted COMMIT. Two things go wrong without it, both measured: the +/// rollback's own complaint replaces the database's exception, so a constraint +/// violation at commit time reaches the client as a bookkeeping error with no +/// inner exception; and because the replacement is not an +/// OperationCanceledException, a client that disconnects mid-commit is +/// audited as a failure, captured by IErrorTrackingProvider and answered +/// 500 instead of 499 — three ADR-0032 behaviours inverted at once. +/// A faulted commit also leaves the outcome genuinely unknown, which is +/// ADR-0033's +/// Indeterminate, not a failure to roll back. +/// +/// +/// The exception path marks the unit; the fail-Result path does not. +/// ADR-0040 § Nesting: an inner Result.Fail that an outer handler +/// deliberately absorbs is not a failure of the unit — the outer handler took +/// responsibility, and its own work still commits. An exception is, so it calls +/// MarkRollbackOnly before failing its frame. +/// +/// /// What is not here yet. The MUST-class audit write — /// IAuditStore.WritePendingAsync(unitOfWork, ct) immediately before -/// COMMIT, per -/// ADR-0033 -/// — belongs on this exact line and lands with IAuditStore in +/// COMMIT, per ADR-0033 — belongs on the marked line and lands with +/// IAuditStore in /// Packet 9, /// together with the IAuditStateCapture transitions that make the commit /// the only place durability is claimed. The commit boundary is here now so that @@ -55,7 +75,11 @@ public async Task Handle( { ArgumentNullException.ThrowIfNull(next); - await unitOfWork.BeginTransactionAsync(cancellationToken); + // Through the handle, not the frame-blind CommitAsync: the handle knows + // its own depth, so a nested frame nobody resolved is an exception here + // rather than a commit that quietly resolves the wrong frame, writes + // nothing, and returns success. + var scope = await unitOfWork.BeginTransactionAsync(cancellationToken); // First statement inside the transaction, per ADR-0003 Amendment 3. Until // Packet 7's TenantResolverMiddleware populates ITenantContext this writes @@ -64,6 +88,8 @@ public async Task Handle( // NULL predicate and every tenant-owned table returns zero rows. await unitOfWork.SetTenantContextAsync(tenantContext, cancellationToken); + var committing = false; + try { var response = await next(); @@ -72,8 +98,9 @@ public async Task Handle( { // A business-rule failure is not an exception (ADR-0032 // § Sub-decision 4), and it still must not commit: the handler - // may have written before deciding it could not finish. - await unitOfWork.RollbackAsync(CancellationToken.None); + // may have written before deciding it could not finish. It does + // not mark the unit — see the class remarks. + await scope.FailAsync(CancellationToken.None); return response; } @@ -82,19 +109,25 @@ public async Task Handle( // await auditStore.WritePendingAsync(unitOfWork, cancellationToken); // It throws on failure, which reaches the catch below and rolls the // business write back, which is what ADR-0033 means by fail-closed. - await unitOfWork.CommitAsync(cancellationToken); + committing = true; + await scope.CompleteAsync(cancellationToken); return response; } - catch + catch when (!committing) { + // An exception marks the unit, so an outer frame that absorbs it + // cannot commit a partial one (ADR-0040 § Nesting). + unitOfWork.MarkRollbackOnly(); + // CancellationToken.None: the rollback is the cleanup path, and a // cancelled rollback leaves the transaction open on a connection // about to go back to the pool. - await unitOfWork.RollbackAsync(CancellationToken.None); + await scope.FailAsync(CancellationToken.None); - // Rethrown, not swallowed: AuditLogBehavior one frame out catches it, - // audits the failure and rethrows through ExceptionDispatchInfo, and - // the L1 IExceptionHandler turns it into Problem Details. + // Rethrown, not swallowed: AuditLogBehavior — three behaviors out, + // at step 3 — catches it, audits the failure and rethrows through + // ExceptionDispatchInfo, and the L1 IExceptionHandler turns it into + // Problem Details. throw; } } diff --git a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs index a5c9b22c..73f84652 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs @@ -37,8 +37,19 @@ public static class ModuleDbContextRegistration /// a structural assertion about the composition root, and a test that built /// its own container would otherwise assert about that container instead. /// - public static IReadOnlyCollection RegisteredContexts => - new ReadOnlyCollection([.. Registered]); + public static IReadOnlyCollection RegisteredContexts + { + get + { + // Inside the lock. HashSet is not safe to enumerate while another + // thread adds, and composition roots do run in parallel — the test + // host builds one per fixture. + lock (Registered) + { + return new ReadOnlyCollection([.. Registered]); + } + } + } /// /// Registers scoped, built on the ambient @@ -80,6 +91,14 @@ public static IServiceCollection AddModuleDbContext(this IServiceColle // does not return the connection to the pool underneath its // siblings — IUnitOfWork is the sole owner. .UseNpgsql(unitOfWork.Connection) + // Without this EF has no ILoggerFactory to resolve, and every + // Microsoft.EntityFrameworkCore log category is silent for every + // context this helper registers — on a seam whose whole premise is + // that a misconfigured context fails invisibly. Measured: the + // model-validation warnings and the command-error lines carrying + // the failing SQL both disappear. It is also what lets a + // DI-registered interceptor be found, which Packet 9 needs. + .UseApplicationServiceProvider(provider) .Options; var context = (TContext)Activator.CreateInstance(typeof(TContext), options)!; diff --git a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs index 182497d3..7adb136a 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs @@ -12,21 +12,30 @@ namespace LearnStack.Infrastructure.Persistence; /// /// /// Registered scoped. It is the sole owner of the connection — every -/// module DbContext is built against it with -/// contextOwnsConnection: false, so disposing a context does not return -/// the connection to the pool underneath its siblings. Disposal order is +/// module DbContext is built against it, so disposing a context does not +/// return the connection to the pool underneath its siblings. Disposal order is /// transaction, then connection, and disposing with a live transaction rolls it /// back /// (ADR-0040 /// § Consequences). /// /// -/// Frames, not a boolean. Nesting is tracked as a depth because -/// TransactionBehavior calls CommitAsync directly rather than -/// through the handle, and ADR-0040 § Nesting says a nested frame "never commits, -/// never rolls back". Only a depth counter can make that true of a bare -/// CommitAsync: an inner call resolves its own frame and the outermost one -/// touches the database. +/// Frames, not a boolean. Nesting is a depth because a joiner's terminal +/// call must resolve its own frame and nothing else, and because +/// takes no argument. The frame-blind form +/// is kept for a caller with no handle; is the +/// guarded one, and it is what TransactionBehavior uses. +/// +/// +/// Three rules that only look like details. A commit disposes its +/// transaction in a finally, so a faulted COMMIT still leaves a +/// clean unit. A rollback on a unit with nothing to resolve is a no-op, because +/// rollback is cleanup and cleanup must never throw over the exception it is +/// cleaning up after — measured, the strict form replaced every commit-time +/// exception with "no transaction frame is open". And +/// is sticky for the life of the unit rather than +/// of the transaction, because the interface says "irreversible" and a poison +/// that a later BEGIN clears is not. /// /// public sealed class NpgsqlUnitOfWork(NpgsqlDataSource dataSource) : IUnitOfWork @@ -38,6 +47,7 @@ public sealed class NpgsqlUnitOfWork(NpgsqlDataSource dataSource) : IUnitOfWork private DbTransaction? _transaction; private int _depth; private bool _rollbackOnly; + private bool _commitRequested; private bool _disposed; public DbConnection Connection @@ -68,6 +78,14 @@ public async Task BeginTransactionAsync( { ObjectDisposedException.ThrowIf(_disposed, this); + if (_rollbackOnly) + { + throw new InvalidOperationException( + "This unit of work is marked rollback-only and cannot open a transaction. " + + "The mark is irreversible for the life of the unit; a caller that needs a " + + "fresh transaction takes a fresh scope, which is the model ADR-0040 decides."); + } + if (_transaction is null) { _connection ??= _dataSource.CreateConnection(); @@ -78,7 +96,6 @@ public async Task BeginTransactionAsync( } _transaction = await _connection.BeginTransactionAsync(cancellationToken); - _rollbackOnly = false; } return new Frame(this, ++_depth); @@ -122,45 +139,25 @@ await ExecuteAsync( : string.Empty)); } - public async Task CommitAsync(CancellationToken cancellationToken = default) + public Task CommitAsync(CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); EnsureFrameOpen(); - if (--_depth > 0) - { - // A joiner's commit is not a commit. - return; - } - - if (_rollbackOnly) - { - await RollbackCoreAsync(); - - throw new InvalidOperationException( - "The ambient transaction is marked rollback-only and has been rolled back. " - + "An inner frame failed, so committing here would commit a partial unit."); - } - - var transaction = _transaction!; - _transaction = null; - await transaction.CommitAsync(cancellationToken); - await transaction.DisposeAsync(); + return CommitFrameAsync(cancellationToken); } - public async Task RollbackAsync(CancellationToken cancellationToken = default) + public Task RollbackAsync(CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); - EnsureFrameOpen(); - - _rollbackOnly = true; - - if (--_depth > 0) - { - return; - } - await RollbackCoreAsync(); + // No EnsureFrameOpen. A rollback is the cleanup path: on a unit a faulted + // commit already resolved there is nothing to roll back, and throwing + // here would replace the caller's real exception with a complaint about + // bookkeeping. + return _depth == 0 || _transaction is null + ? Task.CompletedTask + : RollbackFrameAsync(); } public void MarkRollbackOnly() @@ -180,6 +177,8 @@ public async ValueTask DisposeAsync() // A scope that ended with a live transaction has failed: committing here // would commit work nobody claimed was finished. + var swallowedCommit = _commitRequested && _transaction is not null; + if (_transaction is not null) { await RollbackCoreAsync(); @@ -190,6 +189,74 @@ public async ValueTask DisposeAsync() await _connection.DisposeAsync(); _connection = null; } + + if (swallowedCommit) + { + // A commit was asked for and the transaction outlived it, which means + // a frame opened below the committer was never resolved and the + // frame-blind CommitAsync resolved that one instead. The caller has + // already been told it succeeded. Throwing from disposal is the last + // remaining place to say otherwise, and a silent no-op on the success + // path is the worse outcome. IUnitOfWorkScope.CompleteAsync catches + // this at the terminal call instead, which is why TransactionBehavior + // uses it. + throw new InvalidOperationException( + "A commit was requested but the transaction was still open at disposal, so it " + + "was rolled back after the caller had been told it succeeded. A nested " + + "BeginTransactionAsync frame was never resolved — resolve frames through the " + + "IUnitOfWorkScope handle, which refuses to complete out of order."); + } + } + + private async Task CommitFrameAsync(CancellationToken cancellationToken) + { + _commitRequested = true; + + if (--_depth > 0) + { + // A joiner's commit is not a commit. + return; + } + + if (_rollbackOnly) + { + await RollbackCoreAsync(); + + throw new InvalidOperationException( + "The ambient transaction is marked rollback-only and has been rolled back. " + + "An inner frame failed, so committing here would commit a partial unit."); + } + + var transaction = _transaction!; + _transaction = null; + + try + { + await transaction.CommitAsync(cancellationToken); + } + finally + { + // In a finally, so a faulted COMMIT still leaves the connection clean. + // Deliberately NOT rolled back: a COMMIT that threw leaves the + // server-side outcome genuinely unknown, and ADR-0033 calls that state + // Indeterminate rather than failed. + await transaction.DisposeAsync(); + } + } + + private async Task RollbackFrameAsync() + { + if (--_depth > 0) + { + // A joiner declining its own frame. It does NOT mark the unit: + // ADR-0040 § Nesting reserves that for an exception or an explicit + // MarkRollbackOnly, because an inner Result.Fail the outer handler + // absorbs is the outer handler's to decide about. + return; + } + + _rollbackOnly = true; + await RollbackCoreAsync(); } private async Task RollbackCoreAsync() @@ -203,11 +270,17 @@ private async Task RollbackCoreAsync() return; } - // CancellationToken.None: a rollback is the cleanup path, and cancelling - // it would leave the transaction open on a connection about to go back to - // the pool. - await transaction.RollbackAsync(CancellationToken.None); - await transaction.DisposeAsync(); + try + { + // CancellationToken.None: a rollback is the cleanup path, and + // cancelling it would leave the transaction open on a connection + // about to go back to the pool. + await transaction.RollbackAsync(CancellationToken.None); + } + finally + { + await transaction.DisposeAsync(); + } } private void EnsureFrameOpen() @@ -215,8 +288,8 @@ private void EnsureFrameOpen() if (_depth == 0 || _transaction is null) { throw new InvalidOperationException( - "No transaction frame is open. CommitAsync and RollbackAsync resolve a frame " - + "opened by BeginTransactionAsync."); + "No transaction frame is open. CommitAsync resolves a frame opened by " + + "BeginTransactionAsync."); } } @@ -236,6 +309,12 @@ private async Task ExecuteAsync( } /// One frame of the ambient transaction. + /// + /// It knows its own depth, which is what the frame-blind + /// cannot: resolving while a frame + /// opened after this one is still open would commit nothing and report + /// success. + /// private sealed class Frame(NpgsqlUnitOfWork unitOfWork, int depth) : IUnitOfWorkScope { private bool _resolved; @@ -244,19 +323,43 @@ private sealed class Frame(NpgsqlUnitOfWork unitOfWork, int depth) : IUnitOfWork public Task CompleteAsync(CancellationToken cancellationToken = default) { - if (_resolved || unitOfWork._depth < depth) + if (AlreadyResolved()) { - // Already resolved, here or by an explicit CommitAsync. return Task.CompletedTask; } + EnsureInnermost(); _resolved = true; return unitOfWork.CommitAsync(cancellationToken); } + public Task FailAsync(CancellationToken cancellationToken = default) + { + if (AlreadyResolved()) + { + return Task.CompletedTask; + } + + _resolved = true; + + if (unitOfWork._depth > depth) + { + // Frames above this one leaked. On the failure path that collapses + // rather than throws: everything opened after a frame that failed + // has failed too, and raising here would replace the caller's real + // exception with one about bookkeeping. CompleteAsync is where the + // same condition is loud, because there it would otherwise be + // reported as success. + unitOfWork.MarkRollbackOnly(); + return unitOfWork.RollbackCoreAsync(); + } + + return unitOfWork.RollbackAsync(cancellationToken); + } + public async ValueTask DisposeAsync() { - if (_resolved || unitOfWork._disposed || unitOfWork._depth < depth) + if (AlreadyResolved()) { return; } @@ -264,5 +367,19 @@ public async ValueTask DisposeAsync() _resolved = true; await unitOfWork.RollbackAsync(CancellationToken.None); } + + private bool AlreadyResolved() => + _resolved || unitOfWork._disposed || unitOfWork._depth < depth; + + private void EnsureInnermost() + { + if (unitOfWork._depth > depth) + { + throw new InvalidOperationException( + $"Frame {depth} cannot complete while frame {unitOfWork._depth} is still open. " + + "Frames resolve innermost-first; completing out of order would resolve " + + "someone else's frame, commit nothing, and report success."); + } + } } } diff --git a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs index ae4cdcc8..03b32110 100644 --- a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs +++ b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs @@ -28,11 +28,16 @@ namespace LearnStack.SharedKernel.Persistence; /// Nesting. An application contract may reach a second handler through /// ISender, so a second on a live /// transaction is reachable. The outermost call owns the transaction and is the -/// only one whose commits; an inner call joins, and its -/// commit resolves its own frame and nothing else. An inner -/// — or an explicit -/// — makes the whole unit unable to commit, so a -/// partial unit is never committed by an outer frame that did not notice. +/// only one whose terminal call touches the database; an inner call joins, and +/// its commit or rollback resolves its own frame and nothing else. +/// +/// +/// What escalates a frame's failure to the whole unit is +/// , and only two things call it: an exception — +/// TransactionBehavior marks the unit before rolling back on one — and a +/// caller doing so deliberately. An inner Result.Fail that an outer +/// handler absorbs is not one of them, per ADR-0040 § Nesting: the outer handler +/// took responsibility for it, and its own work still commits. /// /// /// One command at a time. One connection means the ambient transaction @@ -69,9 +74,11 @@ public interface IUnitOfWork : IAsyncDisposable /// Joins the ambient transaction if one is active; otherwise opens it. /// /// - /// A handle for the frame this call opened. Completing it resolves that - /// frame; disposing it without completing rolls the unit back, because a - /// frame that ended without an explicit terminal call has failed and + /// A handle for the frame this call opened. Resolving it through the handle + /// rather than through is what makes a leaked inner + /// frame loud: the handle knows its own depth and refuses to resolve while a + /// frame opened after it is still open. Disposing it unresolved rolls the unit + /// back, because a frame that ended without a terminal call has failed and /// committing it would commit work nobody claimed was finished. /// Task BeginTransactionAsync(CancellationToken cancellationToken = default); @@ -95,22 +102,52 @@ public interface IUnitOfWork : IAsyncDisposable /// unless the unit is marked rollback-only, in which case it throws rather /// than committing a partial unit. /// + /// + /// Frame-blind: it resolves whatever frame is innermost, so a caller that + /// leaked one silently downgrades its own commit to a no-op. + /// is the guarded form and is + /// what TransactionBehavior uses; this exists for a caller that has no + /// handle to hand. + /// Task CommitAsync(CancellationToken cancellationToken = default); /// - /// Resolves the innermost open frame by failing it. The unit becomes - /// rollback-only, and the outermost frame performs the actual - /// ROLLBACK. + /// Resolves the innermost open frame by failing it. /// + /// + /// + /// A joiner's failure does not poison the unit. ADR-0040 § Nesting: + /// "an inner Result.Fail that the outer handler deliberately absorbs + /// is not a failure and does not mark it — only an exception, or an + /// explicit , does." So this resolves the + /// caller's frame and, on the outermost one, performs the actual + /// ROLLBACK; escalating to the whole unit is + /// 's job, and the exception path calls it. + /// + /// + /// It is cleanup, so it never throws over the thing it is cleaning up + /// after. On a unit with nothing left to resolve — the state a faulted + /// leaves behind — this is a no-op. The alternative + /// was measured: it replaced every commit-time exception with + /// "no transaction frame is open", including the + /// OperationCanceledException that three separate ADR-0032 behaviours + /// key on. + /// + /// Task RollbackAsync(CancellationToken cancellationToken = default); /// - /// Marks the ambient transaction as unable to commit. Irreversible. + /// Marks the unit as unable to commit. Irreversible, for the life of the unit + /// of work — not just of the current transaction. /// /// /// An inner Result.Fail that an outer handler deliberately absorbs is - /// not a failure and does not mark it — only an exception, an explicit - /// , or this call does. + /// not a failure and does not mark it; an exception is, and + /// TransactionBehavior calls this before rolling back on one. Once + /// marked, throws and + /// refuses to open a new transaction on + /// the same unit — a scope that needs a fresh one takes a fresh scope, which + /// is the model. /// void MarkRollbackOnly(); } @@ -119,12 +156,19 @@ public interface IUnitOfWork : IAsyncDisposable /// A handle for one frame of the ambient transaction. /// /// -/// Returned by so a caller can -/// write await using and have the frame resolved either way. -/// TransactionBehavior does not use it — it calls -/// / -/// explicitly, because which one it calls depends on the Result the -/// handler returned. +/// +/// Returned by . It carries the +/// depth of the frame it opened, which is the whole reason to prefer it over the +/// frame-blind : a caller that resolves +/// through the handle cannot silently resolve someone else's frame, and a leaked +/// inner frame becomes an exception at the outer frame's terminal call rather +/// than a success that wrote nothing. +/// +/// +/// Two terminal calls rather than one, because which one a caller makes depends +/// on the outcome it is reporting — TransactionBehavior chooses by the +/// Result the handler returned. +/// /// public interface IUnitOfWorkScope : IAsyncDisposable { @@ -138,5 +182,17 @@ public interface IUnitOfWorkScope : IAsyncDisposable /// Resolves this frame successfully. On the owning frame that is the commit; /// on a joiner it is a no-op, per ADR-0040 § Nesting. /// + /// + /// A frame opened after this one is still open. Resolving out of order would + /// commit nothing and report success. + /// Task CompleteAsync(CancellationToken cancellationToken = default); + + /// + /// Resolves this frame by failing it. On the owning frame that is the + /// ROLLBACK; on a joiner it declines this frame without making the + /// unit unable to commit, which is ADR-0040 § Nesting's rule about an + /// absorbed inner failure. + /// + Task FailAsync(CancellationToken cancellationToken = default); } diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index 755b1982..a15363f0 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -172,12 +172,33 @@ public void TransactionBehavior_Does_Not_Reference_A_Module_Assembly() // has to name a module. Two assertions: the assembly takes no build-time // reference to one, and the behavior's own surface names IUnitOfWork and // no DbContext. + // The PROJECT file, not only the emitted assembly-reference table. The + // compiler elides a reference whose types the IL never touches, so an + // unused to a module would leave a reflection-only + // check green — a trap this repository has documented twice and moved a + // rule out of this project over. + var project = Path.Combine( + RepositoryPaths.BackendSrc(), "LearnStack.Application", "LearnStack.Application.csproj"); + + XDocument.Load(project) + .Descendants("ProjectReference") + .Select(element => element.Attribute("Include")?.Value ?? string.Empty) + .Should().NotContain( + include => include.Contains("LearnStack.Modules.", StringComparison.Ordinal), + "LearnStack.Application is generic over every module and references none"); + var application = typeof(TransactionBehavior<,>).Assembly; - application.GetReferencedAssemblies() - .Select(reference => reference.Name) - .Should().NotContain(name => name!.StartsWith("LearnStack.Modules.", StringComparison.Ordinal), - "LearnStack.Application is generic over every module and references none"); + var referenced = application.GetReferencedAssemblies() + .Select(reference => reference.Name!) + .ToList(); + + // The positive control the assembly half needs: if this list were empty + // the NotContain below would pass against a check that read nothing. + referenced.Should().Contain("LearnStack.SharedKernel"); + + referenced.Should().NotContain( + name => name.StartsWith("LearnStack.Modules.", StringComparison.Ordinal)); var constructor = typeof(TransactionBehavior<,>).GetConstructors().Single(); diff --git a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs index 82b39d86..2e56299b 100644 --- a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs @@ -304,6 +304,9 @@ private sealed class Frame(NoDatabaseUnitOfWork unitOfWork) : IUnitOfWorkScope public Task CompleteAsync(CancellationToken cancellationToken = default) => unitOfWork.CommitAsync(cancellationToken); + public Task FailAsync(CancellationToken cancellationToken = default) => + unitOfWork.RollbackAsync(cancellationToken); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; } } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs index c03252d0..f5177908 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs @@ -138,6 +138,30 @@ public async Task InitializeAsync() "-f", ContainerScriptPath, ]); + /// + /// Runs one statement as the container's superuser. + /// + /// + /// For the few assertions that need to change the cluster itself rather than + /// its data — ALTER ROLE … BYPASSRLS is the case — which none of the + /// four LearnStack roles may do: learnstack_migration owns tables, not + /// roles, and PostgreSQL requires CREATEROLE plus ADMIN OPTION. + /// Every caller reverts what it did in a finally: the cluster is shared, + /// and an isolation suite running against a bypass role is vacuous. + /// + public async Task ExecuteAsSuperuserAsync(string sql) + { + var result = await _container.ExecAsync( + ["psql", "-v", "ON_ERROR_STOP=1", "-U", "postgres", "-d", Database, "-c", sql]); + + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"Superuser statement failed (exit {result.ExitCode}): {sql}{Environment.NewLine}" + + $"{result.Stderr}{Environment.NewLine}{result.Stdout}"); + } + } + /// Opens a connection as the given role and returns it open. public static async Task OpenAsync( string connectionString, CancellationToken cancellationToken = default) diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index 1a7926e4..71e73eec 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -1,8 +1,12 @@ using FluentAssertions; +using LearnStack.Api.Composition; +using LearnStack.Application.Pipeline; using LearnStack.Infrastructure.Persistence; using LearnStack.Modules.Tenancy.Infrastructure.Persistence; using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Localization; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -79,9 +83,26 @@ public async Task An_unresolved_context_leaves_every_tenant_owned_table_empty() (await ReadAsync(unitOfWork, "SELECT current_setting('app.tenant_id', true)")) .Should().BeEmpty(); + // Every mapped entity type, swept from the model rather than listed. Two + // of the six that a hand-written pair left out are the ones where + // fail-closed is least obvious: tenant_settings, whose USING carries the + // app.scope hatch, and platform_host_to_tenant, whose read policy is an OR + // over app.resolving_host — a GUC SetTenantContextAsync never writes. var context = scope.ServiceProvider.GetRequiredService(); - (await context.Tenants.CountAsync()).Should().Be(0); - (await context.Organizations.CountAsync()).Should().Be(0); + + var counts = new Dictionary(StringComparer.Ordinal); + + foreach (var entity in context.Model.GetEntityTypes()) + { + var table = entity.GetTableName()!; + await using var command = (NpgsqlCommand)unitOfWork.Connection.CreateCommand(); + command.CommandText = $"SELECT count(*) FROM \"{table}\""; + command.Transaction = (NpgsqlTransaction?)unitOfWork.Transaction; + counts[table] = (long)(await command.ExecuteScalarAsync())!; + } + + counts.Should().HaveCount(8, "TenancyDbContext maps eight entity types"); + counts.Should().OnlyContain(entry => entry.Value == 0); await unitOfWork.RollbackAsync(); } @@ -171,22 +192,197 @@ public async Task A_nested_frame_joins_and_its_commit_is_not_a_commit() } [Fact] - public async Task A_nested_failure_makes_the_outer_commit_impossible() + public async Task An_absorbed_inner_failure_still_commits_the_outer_work() { - // ADR-0040 § Nesting: an inner failure marks the transaction - // rollback-only, and the outer commit throws rather than committing a - // partial unit. + // ADR-0040 § Nesting's worked example, through the REAL behavior against a + // real database: an inner frame declines, the outer handler absorbs it and + // reports success, and the outer handler's own row must be there + // afterwards. The first implementation poisoned the unit here — the inner + // rollback set the rollback-only flag before the joiner check — so the + // outer commit threw and the row was discarded. + await using var provider = BuildProvider(); + var slug = $"absorbed-{Guid.CreateVersion7():N}"[..20]; + + try + { + await using (var scope = provider.CreateAsyncScope()) + { + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + var outer = new TransactionBehavior>( + unitOfWork, Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + var inner = new TransactionBehavior>( + unitOfWork, Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + + var result = await outer.Handle( + new Probe(), + async () => + { + await ExecuteAsync(unitOfWork, + """ + INSERT INTO organizations + (id, tenant_id, slug, display_name, status, + created_at, created_by, row_version) + VALUES (uuidv7(), @tenant, @slug, 'Absorbed', 'Active', now(), @actor, 0) + """, + ("tenant", SchemaFixture.TenantA), ("slug", slug), + ("actor", SchemaFixture.Actor)); + + var innerResult = await inner.Handle( + new Probe(), + () => Task.FromResult(Result.FailFor>( + new Error(new LocalizedMessage("lockey_business_rule_violation")))), + default); + + innerResult.IsFailure.Should().BeTrue(); + return Result.Ok("absorbed"); + }, + default); + + result.IsSuccess.Should().BeTrue(); + } + + (await CountOrganizationsAsync(slug)).Should().Be(1L, + "the outer handler took responsibility for the inner failure, and its work commits"); + } + finally + { + await using var platform = await PostgresFixture.OpenAsync( + _schema.Postgres.PlatformConnectionString); + await using var cleanup = new NpgsqlCommand( + "DELETE FROM organizations WHERE slug = @slug", (NpgsqlConnection)platform); + cleanup.Parameters.AddWithValue("slug", slug); + await cleanup.ExecuteNonQueryAsync(); + } + } + + [Fact] + public async Task A_leaked_inner_frame_makes_the_outer_completion_throw() + { + // The frame-blind CommitAsync cannot tell the owning frame's commit from a + // joiner's, so a frame nobody resolved turns the outer commit into a + // silent no-op: success reported, nothing written. Resolving through the + // handle is what catches it, and this is why TransactionBehavior uses the + // handle rather than the bare call. await using var provider = BuildProvider(); await using var scope = provider.CreateAsyncScope(); var unitOfWork = scope.ServiceProvider.GetRequiredService(); - await unitOfWork.BeginTransactionAsync(); + var outer = await unitOfWork.BeginTransactionAsync(); await unitOfWork.SetTenantContextAsync(Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); - await unitOfWork.BeginTransactionAsync(); + await unitOfWork.BeginTransactionAsync(); // leaked: never resolved + + var complete = async () => await outer.CompleteAsync(); + + (await complete.Should().ThrowAsync()) + .WithMessage("*innermost-first*"); + + await unitOfWork.RollbackAsync(); await unitOfWork.RollbackAsync(); + } - var commit = async () => await unitOfWork.CommitAsync(); + [Fact] + public async Task MarkRollbackOnly_outlives_the_transaction_it_marked() + { + // The interface says "irreversible". The first implementation cleared the + // flag inside BeginTransactionAsync, so a unit marked before a transaction + // was opened — or between two on the same scope — committed anyway. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + unitOfWork.MarkRollbackOnly(); + + var begin = async () => await unitOfWork.BeginTransactionAsync(); + + (await begin.Should().ThrowAsync()) + .WithMessage("*rollback-only*"); + } + + [Fact] + public async Task The_runtime_role_does_not_bypass_row_security() + { + // Asked of the server rather than of the connection string, because the + // name is not the privilege: learnstack_app could have been granted + // BYPASSRLS, and a superuser bypasses row security with rolbypassrls + // false. The composition root's data source runs this on every physical + // connection; here it is asserted directly of the role the suite uses. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var command = new NpgsqlCommand( + "SELECT rolbypassrls OR rolsuper FROM pg_roles WHERE rolname = current_user", + (NpgsqlConnection)connection); + + (await command.ExecuteScalarAsync()).Should().Be(false, + "every isolation assertion in this suite is vacuous against a bypass role"); + } + + [Fact] + public async Task The_data_source_refuses_a_runtime_role_that_was_granted_bypass() + { + // The name check cannot see this one: the connection string still says + // learnstack_app. Only the server knows the role was granted BYPASSRLS, + // and with it every policy in the database is inert — so the composition + // root asks, once per physical connection. + // + // The grant is made and reverted here rather than mocked, because the + // question is whether the initializer actually runs and actually reads + // the right catalogue column. Reverted in a finally: the cluster is shared + // with every other case in this collection, and all of them are vacuous + // against a bypass role. + try + { + // The superuser, because none of the four LearnStack roles may alter a + // role — learnstack_migration owns tables, and PostgreSQL wants + // CREATEROLE plus ADMIN OPTION. + await _schema.Postgres.ExecuteAsSuperuserAsync("ALTER ROLE learnstack_app BYPASSRLS"); + + await using var dataSource = PersistenceCompositionExtensions.BuildApplicationDataSource( + _schema.Postgres.AppConnectionString); + + var open = async () => + { + await using var connection = await dataSource.OpenConnectionAsync(); + }; + + (await open.Should().ThrowAsync()) + .WithMessage("*bypasses Row Level Security*"); + } + finally + { + await _schema.Postgres.ExecuteAsSuperuserAsync("ALTER ROLE learnstack_app NOBYPASSRLS"); + } + + // And the same data source is fine once the grant is gone, so the guard is + // a guard rather than a permanent refusal. + await using var restored = PersistenceCompositionExtensions.BuildApplicationDataSource( + _schema.Postgres.AppConnectionString); + await using var healthy = await restored.OpenConnectionAsync(); + + healthy.State.Should().Be(System.Data.ConnectionState.Open); + } + + [Fact] + public async Task A_nested_failure_makes_the_outer_commit_impossible() + { + // ADR-0040 § Nesting: an EXCEPTION in an inner frame marks the unit, and + // the outer commit then throws rather than committing a partial one. + // + // The escalation is MarkRollbackOnly, not the inner rollback — that is + // the distinction the ADR draws and the one the first implementation + // collapsed. An inner rollback alone is + // An_absorbed_inner_failure_still_commits_the_outer_work, and it commits. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + var outer = await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync(Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + + var inner = await unitOfWork.BeginTransactionAsync(); + unitOfWork.MarkRollbackOnly(); + await inner.FailAsync(); + + var commit = async () => await outer.CompleteAsync(); (await commit.Should().ThrowAsync()) .WithMessage("*rollback-only*"); @@ -286,6 +482,17 @@ private ServiceProvider BuildProvider() return services.BuildServiceProvider(); } + private async Task CountOrganizationsAsync(string slug) + { + await using var platform = await PostgresFixture.OpenAsync( + _schema.Postgres.PlatformConnectionString); + await using var command = new NpgsqlCommand( + "SELECT count(*) FROM organizations WHERE slug = @slug", (NpgsqlConnection)platform); + command.Parameters.AddWithValue("slug", slug); + + return (long)(await command.ExecuteScalarAsync())!; + } + private async Task CountOutboxAsync(string correlation) { await using var platform = await PostgresFixture.OpenAsync( @@ -329,6 +536,9 @@ private static StubTenantContext Resolved(Guid tenant, Guid organization) => /// A resolved context, standing in for what Packet 7's /// TenantResolverMiddleware will populate. /// + /// A request type for driving the real behavior. + public sealed record Probe : MediatR.IRequest>; + private sealed class StubTenantContext(Guid tenant, Guid organization) : ITenantContext { public bool IsResolved => true; diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs new file mode 100644 index 00000000..4b51b15a --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs @@ -0,0 +1,116 @@ +using FluentAssertions; +using LearnStack.Api.Composition; +using Xunit; + +namespace LearnStack.Tests.Unit.Api.Composition; + +/// +/// What ConnectionStrings:Default is allowed to name. +/// +/// +/// +/// This is the single control standing between a pasted connection string and +/// every Row Level Security policy in the database going inert. The two +/// BYPASSRLS roles sit two and three lines away from Default in +/// .env.example, and with either of them here Packet 6's fail-closed +/// state — an unresolved tenant context, so app.tenant_id = '' — stops +/// returning no rows and starts returning every tenant's. +/// +/// +/// The symmetric guard for the migration credential has existed since Packet 6 +/// step 3, in the migrate target. This is the runtime half, and it was +/// missing: the composition root argued for learnstack_app in two +/// paragraphs of remarks and then built a data source from whatever the key held. +/// +/// +public sealed class ApplicationDataSourceGuardTests +{ + // Every literal below is an INPUT to the guard under test — refusing or + // accepting connection strings is the whole of what it does, so the file + // cannot be written without them. They name localhost and a password no + // service has ever had. leakwatch:ignore applies per line. + private const string Valid = + "Host=localhost;Port=5432;Database=learnstack;Username=learnstack_app;Password=s3cret"; // leakwatch:ignore + + [Fact] + public void The_application_role_is_accepted() + { + // Building the data source opens nothing — the physical-connection check + // that asks the server about rolbypassrls runs on first connect, and is + // covered against a real database in the integration suite. + var build = () => PersistenceCompositionExtensions.BuildApplicationDataSource(Valid); + + build.Should().NotThrow(); + } + + [Theory] + [InlineData("learnstack_migration")] + [InlineData("learnstack_platform")] + [InlineData("learnstack_outbox_admin")] + [InlineData("postgres")] + public void Any_other_role_is_refused_by_name(string role) + { + var build = () => PersistenceCompositionExtensions.BuildApplicationDataSource( + $"Host=localhost;Database=learnstack;Username={role};Password=s3cret"); // leakwatch:ignore + + build.Should().Throw() + .WithMessage($"*Username='{role}'*") + .And.Message.Should().Contain("learnstack_app"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void An_absent_value_names_the_key_it_is_missing_from(string? value) + { + var build = () => PersistenceCompositionExtensions.BuildApplicationDataSource(value); + + build.Should().Throw() + .WithMessage("*ConnectionStrings:Default*"); + } + + [Theory] + // A URI-style DSN is the likely malformed value: it is the form DATABASE_URL + // carries on several hosts. Npgsql's own exception names neither the key nor + // the expected form. + [InlineData("postgres://learnstack_app:pw@localhost:5432/learnstack")] // leakwatch:ignore + [InlineData("Host=localhost;Port=not-a-number;Username=learnstack_app")] + public void A_malformed_value_names_the_key_and_the_expected_form(string value) + { + var build = () => PersistenceCompositionExtensions.BuildApplicationDataSource(value); + + build.Should().Throw() + .WithMessage("*ConnectionStrings:Default*") + .And.Message.Should().Contain("key/value"); + } + + [Fact] + public void No_message_carries_the_password() + { + // This is the one place a runtime credential is read. An error that echoed + // it would put it in every log that captured the startup failure — the + // mistake the migrate target already made once and fixed. + var messages = new List(); + + foreach (var value in new[] + { + "Host=localhost;Username=learnstack_platform;Password=hunter2", // leakwatch:ignore + "Host=localhost;Port=nope;Username=learnstack_app;Password=hunter2", // leakwatch:ignore + }) + { + try + { + PersistenceCompositionExtensions.BuildApplicationDataSource(value); + } + catch (InvalidOperationException exception) + { + messages.Add(exception.Message); + } + } + + messages.Should().HaveCount(2, "both values are refused"); + messages.Should().OnlyContain(message => !message.Contains("hunter2", StringComparison.Ordinal)); + messages.Should().OnlyContain(message => message.Contains("Password=***", StringComparison.Ordinal)); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs index 701b18a7..d134b6a4 100644 --- a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs @@ -12,12 +12,22 @@ namespace LearnStack.Tests.Unit.Application.Pipeline; /// /// TransactionBehavior — step 6. The commit boundary: what it calls, in -/// what order, and which outcome resolves the transaction which way. +/// what order, which outcome resolves the transaction which way, and what +/// survives a terminal call that itself fails. /// /// +/// /// Against a recording IUnitOfWork rather than a database, because the /// question here is the protocol. Whether the protocol produces the right rows in /// PostgreSQL is UnitOfWorkTests, which runs against a real one. +/// +/// +/// The fake models nesting depth and can fail its terminal call. Its first +/// version did neither, and could therefore not fail against either of the two +/// defects this class now covers: a commit-time exception replaced by the +/// rollback's own complaint, and an absorbed inner failure poisoning the outer +/// frame. A fake that cannot reach the failure is a test that cannot see it. +/// /// public sealed class TransactionBehaviorTests { @@ -40,11 +50,16 @@ public async Task Opens_The_Transaction_Then_Sets_The_Session_Variables_Then_Run } [Fact] - public async Task Rolls_Back_A_Failure_Result() + public async Task Rolls_Back_A_Failure_Result_Without_Marking_The_Unit() { // A business-rule violation is a fail-Result, not an exception // (ADR-0032 § Sub-decision 4) — and it must still not commit: the handler // may have written before deciding it could not finish. + // + // It must ALSO not call MarkRollbackOnly. ADR-0040 § Nesting: an inner + // Result.Fail that an outer handler deliberately absorbs is not a failure + // of the unit. Marking it here would poison the outer frame, which is the + // one case the ADR names and forbids. var unitOfWork = new RecordingUnitOfWork(); var behavior = Build(unitOfWork); @@ -56,14 +71,18 @@ public async Task Rolls_Back_A_Failure_Result() result.IsFailure.Should().BeTrue(); unitOfWork.Calls.Should().Equal("begin", "set-tenant", "handler", "rollback"); + unitOfWork.Calls.Should().NotContain("mark-rollback-only"); } [Fact] - public async Task Rolls_Back_And_Rethrows_An_Exception() + public async Task Rolls_Back_And_Rethrows_An_Exception_After_Marking_The_Unit() { - // Rethrown rather than converted: AuditLogBehavior one frame out catches - // it, audits the failure and rethrows through ExceptionDispatchInfo, and - // the L1 IExceptionHandler turns it into Problem Details. + // An exception IS a failure of the unit, so the mark comes first: an + // outer frame that absorbs the exception must not then commit a partial + // one. Rethrown rather than converted — AuditLogBehavior, three behaviors + // out at step 3, audits the failure and rethrows through + // ExceptionDispatchInfo, and the L1 IExceptionHandler turns it into + // Problem Details. var unitOfWork = new RecordingUnitOfWork(); var behavior = Build(unitOfWork); @@ -78,7 +97,116 @@ public async Task Rolls_Back_And_Rethrows_An_Exception() (await act.Should().ThrowAsync()) .WithMessage("handler blew up"); - unitOfWork.Calls.Should().Equal("begin", "set-tenant", "handler", "rollback"); + unitOfWork.Calls.Should().Equal( + "begin", "set-tenant", "handler", "mark-rollback-only", "rollback"); + } + + [Fact] + public async Task A_Failed_Commit_Reaches_The_Caller_Unchanged() + { + // The defect this case exists for: the cleanup path used to run after a + // faulted COMMIT, and the rollback's own complaint replaced the database's + // exception with no inner exception. A constraint violation deferred to + // commit time — or a cancellation — arrived as a bookkeeping error. + var unitOfWork = new RecordingUnitOfWork { CommitFailure = new DbTestException("23505") }; + var behavior = Build(unitOfWork); + + var act = async () => await behavior.Handle( + new DummyCommand(), () => Next(unitOfWork, Result.Ok("ok")), default); + + (await act.Should().ThrowAsync()).WithMessage("23505"); + unitOfWork.Calls.Should().Equal("begin", "set-tenant", "handler", "commit"); + unitOfWork.Calls.Should().NotContain("rollback", + "a faulted COMMIT leaves the outcome unknown — ADR-0033's Indeterminate — " + + "and rolling back on top of it is both wrong and what destroyed the exception"); + } + + [Fact] + public async Task A_Cancelled_Commit_Stays_An_OperationCanceledException() + { + // Three ADR-0032 behaviours key on the exception TYPE: AuditLogBehavior + // skips its catch for OperationCanceledException, HttpStatusMap answers + // 499, and the error-tracking provider does not capture it. Replacing it + // with an InvalidOperationException inverted all three at once. + var unitOfWork = new RecordingUnitOfWork + { + CommitFailure = new OperationCanceledException("client went away"), + }; + var behavior = Build(unitOfWork); + + var act = async () => await behavior.Handle( + new DummyCommand(), () => Next(unitOfWork, Result.Ok("ok")), default); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task An_Absorbed_Inner_Failure_Leaves_The_Outer_Frame_Able_To_Commit() + { + // ADR-0040 § Nesting's worked example, end to end: an application contract + // reaches a second handler through ISender, that handler returns a + // fail-Result, and the outer handler absorbs it and reports success. The + // outer handler's own work must still commit. + var unitOfWork = new RecordingUnitOfWork(); + var outer = Build(unitOfWork); + var inner = Build(unitOfWork); + + var failure = Result.FailFor>( + new Error(new LocalizedMessage("lockey_business_rule_violation"))); + + var result = await outer.Handle( + new DummyCommand(), + async () => + { + unitOfWork.Calls.Add("outer-handler"); + + var innerResult = await inner.Handle( + new DummyCommand(), () => Next(unitOfWork, failure), default); + + innerResult.IsFailure.Should().BeTrue(); + return Result.Ok("absorbed"); + }, + default); + + result.IsSuccess.Should().BeTrue(); + unitOfWork.Committed.Should().BeTrue( + "the outer handler took responsibility for the inner failure, and its own work commits"); + unitOfWork.Calls.Should().Equal( + "begin", "set-tenant", "outer-handler", + "begin", "handler", "rollback", + "commit"); + } + + [Fact] + public async Task A_Nested_Exception_Makes_The_Outer_Commit_Impossible() + { + // The other half of § Nesting. The inner frame's exception marks the unit, + // so even an outer handler that catches it cannot commit a partial one. + var unitOfWork = new RecordingUnitOfWork(); + var outer = Build(unitOfWork); + var inner = Build(unitOfWork); + + var act = async () => await outer.Handle( + new DummyCommand(), + async () => + { + try + { + return await inner.Handle( + new DummyCommand(), + () => throw new InvalidOperationException("inner blew up"), + default); + } + catch (InvalidOperationException) + { + return Result.Ok("swallowed"); + } + }, + default); + + await act.Should().ThrowAsync() + .WithMessage("*rollback-only*"); + unitOfWork.Committed.Should().BeFalse(); } [Fact] @@ -104,32 +232,51 @@ private static Task> Next(RecordingUnitOfWork unitOfWork, Result< return Task.FromResult(result); } - /// An that records the protocol. - private sealed class RecordingUnitOfWork : IUnitOfWork, IUnitOfWorkScope + /// Stands in for a provider exception the behavior must not touch. + public sealed class DbTestException(string message) : Exception(message); + + /// + /// An that records the protocol and models the parts + /// of it the behavior depends on: frame depth, the rollback-only mark, and a + /// terminal call that can fail. + /// + private sealed class RecordingUnitOfWork : IUnitOfWork { + private int _depth; + private bool _rollbackOnly; + public List Calls { get; } = []; public ITenantContext? TenantContext { get; private set; } + /// Thrown by the outermost commit, to model a faulted COMMIT. + public Exception? CommitFailure { get; init; } + + public bool Committed { get; private set; } + public DbConnection Connection => throw new NotSupportedException("the behavior must never reach for the connection"); public DbTransaction? Transaction => null; - public bool HasActiveTransaction { get; private set; } - - public bool IsOwner => true; + public bool HasActiveTransaction => _depth > 0; public Task BeginTransactionAsync(CancellationToken cancellationToken = default) { Calls.Add("begin"); - HasActiveTransaction = true; - return Task.FromResult(this); + return Task.FromResult(new Frame(this, ++_depth)); } public Task SetTenantContextAsync( ITenantContext context, CancellationToken cancellationToken = default) { + if (_depth > 1) + { + // A joiner. The real seam does the same, so a test that recorded + // it here would assert a call the behavior does not make. + return Task.CompletedTask; + } + Calls.Add("set-tenant"); TenantContext = context; return Task.CompletedTask; @@ -138,22 +285,58 @@ public Task SetTenantContextAsync( public Task CommitAsync(CancellationToken cancellationToken = default) { Calls.Add("commit"); - HasActiveTransaction = false; + + if (--_depth > 0) + { + return Task.CompletedTask; + } + + if (_rollbackOnly) + { + return Task.FromException(new InvalidOperationException( + "The ambient transaction is marked rollback-only and has been rolled back.")); + } + + if (CommitFailure is not null) + { + return Task.FromException(CommitFailure); + } + + Committed = true; return Task.CompletedTask; } public Task RollbackAsync(CancellationToken cancellationToken = default) { + if (_depth == 0) + { + return Task.CompletedTask; + } + Calls.Add("rollback"); - HasActiveTransaction = false; + --_depth; return Task.CompletedTask; } - public Task CompleteAsync(CancellationToken cancellationToken = default) => - CommitAsync(cancellationToken); - - public void MarkRollbackOnly() => Calls.Add("mark-rollback-only"); + public void MarkRollbackOnly() + { + Calls.Add("mark-rollback-only"); + _rollbackOnly = true; + } public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + private sealed class Frame(RecordingUnitOfWork unitOfWork, int depth) : IUnitOfWorkScope + { + public bool IsOwner => depth == 1; + + public Task CompleteAsync(CancellationToken cancellationToken = default) => + unitOfWork.CommitAsync(cancellationToken); + + public Task FailAsync(CancellationToken cancellationToken = default) => + unitOfWork.RollbackAsync(cancellationToken); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } } } diff --git a/docs/architecture/31-audit-subsystem.md b/docs/architecture/31-audit-subsystem.md index b4b6d095..03c4165f 100644 --- a/docs/architecture/31-audit-subsystem.md +++ b/docs/architecture/31-audit-subsystem.md @@ -481,8 +481,22 @@ public async Task Handle(TRequest request, `IUnitOfWork` is the seam that lets this generic behavior open and commit a transaction without naming any module's `DbContext`, and through which `IAuditStore` reaches the -ambient connection. It is a [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) -deliverable that the shipped `TransactionBehavior` shell already presumes. +ambient connection. It **shipped** in +[Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) step 6, together with the +`TransactionBehavior` body — everything above except the `auditStore` and `stateCapture` +lines, which land with `IAuditStore` in Packet 9 and have their place reserved in the +shipped body. + +Two differences between the block above and what shipped, both decided by +[ADR-0040 Amendment 2](../decisions/0040-ambient-unit-of-work.md) after this block was +written. The shipped behavior resolves its frame through the `IUnitOfWorkScope` handle +(`CompleteAsync` / `FailAsync`) rather than through the frame-blind +`unitOfWork.CommitAsync` / `RollbackAsync`, because a nested frame nobody resolved +otherwise turns the outer commit into a silent no-op. And it marks the unit +rollback-only on the exception path only: an inner `Result.Fail` an outer handler +absorbs is not a failure of the unit, per ADR-0040 § Nesting. The `stateCapture` guard +on the outer catch is what the shipped body writes as a `committing` flag, and it does +the same job — a faulted `COMMIT` must not be followed by a rollback attempt. The alternative — moving `TransactionBehavior` outward so it wraps `AuditLogBehavior` — was considered and rejected in diff --git a/docs/decisions/0040-ambient-unit-of-work.md b/docs/decisions/0040-ambient-unit-of-work.md index ba8b8b98..7ae39498 100644 --- a/docs/decisions/0040-ambient-unit-of-work.md +++ b/docs/decisions/0040-ambient-unit-of-work.md @@ -368,6 +368,60 @@ or arrives another way is `TenantResolverMiddleware`; until then no caller sets it and the tenant-scope read hatch is simply unused, which is the correct default. +### Amendment 2 — the handle's shape, and what a joiner's rollback does not do (2026-08-28) + +§ Decision specifies `IUnitOfWork` member by member and leaves `IUnitOfWorkScope` +at one sentence: "Returns a handle whose `Complete()` is a no-op for a joiner." +Implementing it in Packet 6 step 6 fixed three things that sentence does not, and +two of them were found by a review measuring the first implementation against +this ADR. Recording them here rather than leaving the divergence silent, on the +Amendment 1 precedent. + +**The handle is `CompleteAsync` / `FailAsync` / `IsOwner`, and resolving through +it is the guarded path.** `Complete()` in the sketch becomes `CompleteAsync`, +matching every other member. `FailAsync` is added because which terminal call a +caller makes depends on the outcome it is reporting — `TransactionBehavior` +chooses by the `Result` the handler returned — and the alternative was the +frame-blind `IUnitOfWork.RollbackAsync`. + +*Frame-blind* is the word that matters. `CommitAsync` and `RollbackAsync` take no +argument, so they resolve whatever frame is innermost. Measured: a nested frame +nobody resolved makes the outer `CommitAsync` decrement the depth, return without +committing, and hand back a success — nothing written, nothing raised. The handle +carries its own depth, so `CompleteAsync` refuses to resolve while a frame opened +after it is still open, and `TransactionBehavior` uses the handle for exactly that +reason. The bare calls remain for a caller that has no handle to hand. + +**A joiner's rollback does not mark the unit.** § Nesting already decides this — +"an inner `Result.Fail` that the outer handler deliberately absorbs is *not* a +failure and does not mark it — only an exception, or an explicit +`MarkRollbackOnly()`, does" — and the first implementation contradicted it by +setting the flag inside `RollbackAsync` before the joiner check. Measured against +a real database: the outer handler absorbed the inner failure, reported success, +and its own committed row was discarded. The rule is realised by putting the mark +on the outermost frame only, and by having `TransactionBehavior` call +`MarkRollbackOnly()` explicitly on the exception path — which is the one cause +§ Nesting names that a terminal call cannot distinguish on its own. + +**Two robustness rules follow from "irreversible" and from what a rollback is +for.** `MarkRollbackOnly` is sticky for the life of the unit of work, not of the +transaction — the sketch says irreversible, and a poison a later `BEGIN` clears is +not — so `BeginTransactionAsync` refuses to open a transaction on a marked unit. +And `RollbackAsync` on a unit with nothing left to resolve is a no-op rather than +an error, because rollback is the cleanup path and cleanup must never throw over +the exception it is cleaning up after. The strict form was measured replacing +every commit-time exception with "no transaction frame is open" — including the +`OperationCanceledException` that `AuditLogBehavior`, `HttpStatusMap` and +`IErrorTrackingProvider` each key on, so a client disconnecting mid-commit was +audited as a failure, captured, and answered `500` instead of `499`. A faulted +`COMMIT` leaves the outcome genuinely unknown, which is +[ADR-0033](0033-audit-durability-model.md)'s `Indeterminate` rather than something +to roll back; `TransactionBehavior`'s catch is filtered so it does not run after +one. + +None of this changes what the ADR decides — one connection per scope, owned by +`IUnitOfWork`, with every context and cross-cutting writer enlisted on it. + ## References - [ADR-0002 — Initial Architecture](0002-initial-architecture.md) diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 65acc6ea..69d79c3e 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -738,8 +738,12 @@ rules that need a second `DbContext` are owed by Phase 03. #### `TransactionBehavior_Does_Not_Reference_A_Module_Assembly` -- **Asserts:** `TransactionBehavior` names `IUnitOfWork` and no `DbContext`, and - `LearnStack.Application` takes no build-time reference to any module assembly. +- **Asserts:** `TransactionBehavior`'s constructor names `IUnitOfWork` and no + `DbContext`, and `LearnStack.Application` references no module assembly — checked + against the **project file** as well as the emitted assembly-reference table, + because the compiler elides a reference whose types the IL never touches, so a + dangling `` would leave a reflection-only check green. The + assembly half carries a positive control. - **Source:** ADR-0040; ADR-0033. - **Type:** xUnit + assembly-reference and constructor inspection. **Kind:** structural. - **Status:** **Implemented** (Packet 6 step 6, From 695892a4024c284b9b9d257632e74c0f4bf189fd Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 15:35:50 +0300 Subject: [PATCH 20/35] fix(kernel): close the Step 6 Sonnet round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six confirmed findings, no blockers — the Opus round had taken the transaction boundary apart already. Two are code. `Frame.DisposeAsync` was the one path that skipped the ordering guard `CompleteAsync` and `FailAsync` both have. Disposing a frame out of order decremented the shared depth by one and left the transaction open, so the still-open inner frame's completion did nothing and reported success — and a frame opened later joined the abandoned transaction, committed nothing, reported success, and handed the disposal-time exception to that entirely innocent caller. Measured end to end. Disposal now goes through `FailAsync`, which is what it always meant: a frame that ends unresolved has failed, and it has failed in exactly the way `FailAsync` already handles. Nothing in the repository disposes a scope today — `TransactionBehavior` resolves every path explicitly — so this was a contract gap rather than a live defect, and it is closed before Packet 9 or Phase 02b becomes the first consumer to rely on it. The credential guard's password redaction was a keyword regex over the raw connection string. Npgsql accepts `Pwd` and `PSW` as aliases for `Password` and parses all three into the same field, so either alias rode the secret into the exception message. It now clears the field on the parsed builder, which is alias-proof by construction; the regex survives only for the branch where parsing itself failed and there is no builder, and there it covers all three spellings. Both paths are mutation-checked — the first attempt at a mutant was equivalent, because redacting the round-tripped string normalises the aliases away, and the comment now says so, since that is one edit from the form that leaks. The rest is the record. `31-audit-subsystem.md` claimed Packet 9's `stateCapture` lines have "their place reserved in the shipped body"; only the audit write does. `MarkIndeterminate` has no reachable branch at all, because the catch is filtered `when (!committing)` precisely so it does not run after a faulted commit — Packet 9 has to add a `try`/`catch` around the commit, not fill in a line. ADR-0040 Amendment 2 documented `CompleteAsync`'s loud leaked-frame guard and never the deliberate asymmetry with `FailAsync`'s silent collapse. And "frames, not savepoints" describes the unit of work's own counter: EF issues a real `SAVEPOINT` around every `SaveChangesAsync` inside an externally supplied transaction, which is wanted and is now written down where an implementer meets it. The provider call-site scan matched `UseNpgsql` and `AddDbContext` only, so a raw `NpgsqlDataSourceBuilder` or `new NpgsqlConnection(` elsewhere would have passed it. It now covers those too, and names the composition root as the fourth file allowed to reach for a connection. ADR: ADR-0040 (Amendment 2) Co-Authored-By: Claude Opus 5 (1M context) --- .../PersistenceCompositionExtensions.cs | 44 ++++++++++++++++--- .../ModuleDbContextRegistration.cs | 8 ++++ .../Persistence/NpgsqlUnitOfWork.cs | 18 ++++---- .../PersistenceConventionTests.cs | 36 +++++++++++---- .../Database/UnitOfWorkTests.cs | 34 ++++++++++++++ .../ApplicationDataSourceGuardTests.cs | 11 ++++- docs/architecture/31-audit-subsystem.md | 12 ++++- docs/decisions/0040-ambient-unit-of-work.md | 19 ++++++++ 8 files changed, 153 insertions(+), 29 deletions(-) diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 47312276..30f84ab9 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -116,7 +116,8 @@ internal static NpgsqlDataSource BuildApplicationDataSource(string? connectionSt // on several hosts — otherwise gets a bare ArgumentException out of // System.Data.Common. throw new InvalidOperationException( - $"ConnectionStrings:Default is not a valid connection string: {Redact(connectionString)}. " + $"ConnectionStrings:Default is not a valid connection string: " + + $"{RedactUnparsed(connectionString)}. " + "The expected form is a semicolon-separated key/value list — Host, Port, " + "Database, Username, Password — not a URI. See .env.example.", exception); @@ -126,7 +127,7 @@ internal static NpgsqlDataSource BuildApplicationDataSource(string? connectionSt { throw new InvalidOperationException( $"ConnectionStrings:Default names Username='{parsed.Username}', not {RuntimeRole}: " - + $"{Redact(connectionString)}. A runtime process connects as the NOBYPASSRLS " + + $"{Redact(parsed)}. A runtime process connects as the NOBYPASSRLS " + "application role and nothing else. learnstack_migration owns every table, and " + "learnstack_platform and learnstack_outbox_admin hold BYPASSRLS — with any of " + "them here every Row Level Security policy in the database is inert, and the " @@ -168,10 +169,41 @@ private static async Task RefuseBypassRole(NpgsqlConnection connection, bool asy } } - /// The connection string with its password replaced. - private static string Redact(string connectionString) => + /// The connection string with its password removed. + /// + /// From the parsed builder, not by pattern-matching the raw text. + /// Npgsql accepts Pwd and PSW as aliases for Password and + /// parses all three into the same field, so a keyword regex over the raw value + /// that knows only the canonical spelling carries the other two straight into + /// the exception message — measured, and it is what shipped first. Setting the + /// field is alias-proof by construction. (A regex over + /// parsed.ConnectionString would also work, because the round trip + /// normalises the aliases away — but it works for a reason a reader would have + /// to know, and the raw-string form one edit away from it does not.) + /// + private static string Redact(NpgsqlConnectionStringBuilder parsed) + { + var redacted = new NpgsqlConnectionStringBuilder(parsed.ConnectionString) + { + Password = "***", + }; + + return redacted.ConnectionString; + } + + /// + /// The same, for a value Npgsql could not parse — so there is no builder to + /// clear a field on. + /// + /// + /// A regex is the only tool left here, so it covers every alias Npgsql accepts + /// rather than the canonical spelling alone, and it does not have to be + /// exhaustive to be safe: the value it is redacting is one Npgsql rejected, so + /// nothing downstream will treat it as a credential. + /// + private static string RedactUnparsed(string connectionString) => System.Text.RegularExpressions.Regex.Replace( connectionString, - "(?i)(password\\s*=)[^;]*", - "$1***"); + "(?i)\\b(password|pwd|psw)(\\s*=)[^;]*", + "$1$2***"); } diff --git a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs index 73f84652..843cf5a5 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs @@ -24,6 +24,14 @@ namespace LearnStack.Infrastructure.Persistence; /// reads : a context registered any other way is /// absent from that set, which is what the rule asserts against. /// +/// +/// EF issues its own savepoints, and that is left on. A context enlisted in +/// an externally supplied transaction wraps every SaveChangesAsync in a +/// real SAVEPOINT / RELEASE SAVEPOINT, so a failed save rolls back +/// to its own savepoint and leaves the ambient transaction usable. ADR-0040's +/// "frames, not savepoints" describes the unit of work's in-process depth counter, +/// not the connection — the two mechanisms are independent and both are wanted. +/// /// public static class ModuleDbContextRegistration { diff --git a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs index 7adb136a..7980f12a 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs @@ -357,16 +357,14 @@ public Task FailAsync(CancellationToken cancellationToken = default) return unitOfWork.RollbackAsync(cancellationToken); } - public async ValueTask DisposeAsync() - { - if (AlreadyResolved()) - { - return; - } - - _resolved = true; - await unitOfWork.RollbackAsync(CancellationToken.None); - } + public ValueTask DisposeAsync() => + // Through FailAsync, not the frame-blind RollbackAsync: a frame that + // ends unresolved has failed, and it has failed in exactly the way + // FailAsync already handles — including the leaked-frames collapse. + // Calling RollbackAsync here instead decremented the shared depth by + // one and left the transaction open, so a frame opened later joined an + // abandoned transaction and reported a success that never committed. + new(FailAsync(CancellationToken.None)); private bool AlreadyResolved() => _resolved || unitOfWork._disposed || unitOfWork._depth < depth; diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index a15363f0..e823604c 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -138,21 +138,25 @@ public void Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork() // The call-site half: a context on its own connection never saw // SET LOCAL, so every read through it returns zero rows under the - // corrected policy — silently. `UseNpgsql` with a connection string is how - // that happens, and there are exactly three files under backend/src that - // may configure a provider at all: the two design-time factories, where a - // connection string is the point, and the shared helper, which passes a - // connection rather than a string. A fourth is a new decision. + // corrected policy — silently. + // + // Four files under backend/src may reach for a connection at all: the two + // design-time factories, where a connection string is the point; the + // shared helper, which passes a connection rather than a string; and the + // composition root, which builds the one application data source behind + // its credential guard. A fifth is a new decision. + // + // The scan covers the raw constructors as well as `UseNpgsql` and + // `AddDbContext`, because a call site that opened its own + // `NpgsqlConnection` would bypass the seam without naming either. var callSites = Directory .EnumerateFiles(RepositoryPaths.BackendSrc(), "*.cs", SearchOption.AllDirectories) .Where(file => !file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) && !file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) - .Where(file => StripComments(File.ReadAllText(file)) - .Contains("UseNpgsql", StringComparison.Ordinal) - || StripComments(File.ReadAllText(file)) - .Contains("AddDbContext", StringComparison.Ordinal)) + .Where(file => ProviderTokens.Any(token => + StripComments(File.ReadAllText(file)).Contains(token, StringComparison.Ordinal))) .Select(Path.GetFileName) .Order(StringComparer.Ordinal) .ToList(); @@ -160,11 +164,25 @@ public void Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork() callSites.Should().BeEquivalentTo( [ "ModuleDbContextRegistration.cs", + "PersistenceCompositionExtensions.cs", "PlatformDbContextFactory.cs", "TenancyDbContextFactory.cs", ]); } + /// + /// Every way source under backend/src could configure or open a + /// PostgreSQL connection outside the seam. + /// + private static readonly string[] ProviderTokens = + [ + "UseNpgsql", + "AddDbContext", + "NpgsqlDataSourceBuilder", + "NpgsqlDataSource.Create", + "new NpgsqlConnection(", + ]; + [Fact] public void TransactionBehavior_Does_Not_Reference_A_Module_Assembly() { diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index 71e73eec..fdb50fca 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -281,6 +281,40 @@ public async Task A_leaked_inner_frame_makes_the_outer_completion_throw() await unitOfWork.RollbackAsync(); } + [Fact] + public async Task Disposing_a_frame_out_of_order_collapses_the_whole_unit() + { + // A frame that ends unresolved has failed, so disposal goes through the + // same path FailAsync does — including its leaked-frames collapse. + // + // The alternative was measured: the frame-blind rollback decremented the + // shared depth by one and left the transaction open, so the still-open + // inner frame's completion did nothing and reported success, and a frame + // opened later joined an abandoned transaction, committed nothing, and + // handed the exception to that entirely innocent caller. + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + var outer = await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync(Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + var inner = await unitOfWork.BeginTransactionAsync(); + + await outer.DisposeAsync(); + + unitOfWork.HasActiveTransaction.Should().BeFalse( + "the outer frame ended unresolved, so the whole unit is done"); + + // And the abandoned inner frame cannot revive it. + await inner.CompleteAsync(); + unitOfWork.HasActiveTransaction.Should().BeFalse(); + + var begin = async () => await unitOfWork.BeginTransactionAsync(); + + (await begin.Should().ThrowAsync()) + .WithMessage("*rollback-only*", "the collapse marked the unit, so nothing joins it later"); + } + [Fact] public async Task MarkRollbackOnly_outlives_the_transaction_it_marked() { diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs index 4b51b15a..73111f94 100644 --- a/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs @@ -93,10 +93,17 @@ public void No_message_carries_the_password() // mistake the migrate target already made once and fixed. var messages = new List(); + // Pwd and PSW are Npgsql aliases for Password and parse into the same + // field. A keyword regex that knew only the canonical spelling carried + // both straight into the message — measured, which is why the redaction + // now clears the parsed field rather than matching text. foreach (var value in new[] { "Host=localhost;Username=learnstack_platform;Password=hunter2", // leakwatch:ignore + "Host=localhost;Username=learnstack_platform;Pwd=hunter2", // leakwatch:ignore + "Host=localhost;Username=learnstack_platform;PSW=hunter2", // leakwatch:ignore "Host=localhost;Port=nope;Username=learnstack_app;Password=hunter2", // leakwatch:ignore + "Host=localhost;Port=nope;Username=learnstack_app;Pwd=hunter2", // leakwatch:ignore }) { try @@ -109,8 +116,8 @@ public void No_message_carries_the_password() } } - messages.Should().HaveCount(2, "both values are refused"); + messages.Should().HaveCount(5, "every value is refused"); messages.Should().OnlyContain(message => !message.Contains("hunter2", StringComparison.Ordinal)); - messages.Should().OnlyContain(message => message.Contains("Password=***", StringComparison.Ordinal)); + messages.Should().OnlyContain(message => message.Contains("***", StringComparison.Ordinal)); } } diff --git a/docs/architecture/31-audit-subsystem.md b/docs/architecture/31-audit-subsystem.md index 03c4165f..dd0d4c23 100644 --- a/docs/architecture/31-audit-subsystem.md +++ b/docs/architecture/31-audit-subsystem.md @@ -484,8 +484,16 @@ without naming any module's `DbContext`, and through which `IAuditStore` reaches ambient connection. It **shipped** in [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) step 6, together with the `TransactionBehavior` body — everything above except the `auditStore` and `stateCapture` -lines, which land with `IAuditStore` in Packet 9 and have their place reserved in the -shipped body. +lines, which land with `IAuditStore` in Packet 9. + +Only the `auditStore.WritePendingAsync` line has a slot waiting for it, marked by a dated +TODO immediately before the commit. The `stateCapture` calls do not, and one of them needs +more than a line: the shipped body's catch is filtered `when (!committing)` precisely so it +does **not** run after a faulted commit, so there is no reachable branch for +`MarkIndeterminate` to go in. `MarkCommitted` and `MarkRolledBack` drop into the existing +success and failure paths; `MarkIndeterminate` requires Packet 9 to add a `try`/`catch` +around the commit call itself, which is what the block above shows and what the filter +stands in for until then. Two differences between the block above and what shipped, both decided by [ADR-0040 Amendment 2](../decisions/0040-ambient-unit-of-work.md) after this block was diff --git a/docs/decisions/0040-ambient-unit-of-work.md b/docs/decisions/0040-ambient-unit-of-work.md index 7ae39498..b8e6c027 100644 --- a/docs/decisions/0040-ambient-unit-of-work.md +++ b/docs/decisions/0040-ambient-unit-of-work.md @@ -419,6 +419,25 @@ audited as a failure, captured, and answered `500` instead of `499`. A faulted to roll back; `TransactionBehavior`'s catch is filtered so it does not run after one. +**`CompleteAsync` is loud about a leaked frame and `FailAsync` is silent, deliberately.** +Completing while a deeper frame is still open would commit nothing and report success, so it +throws. Failing while a deeper frame is still open is not ambiguous in the same way — +everything opened after a frame that failed has failed too — so it collapses instead: +marks the unit and rolls the whole thing back, without raising. Raising there would put a +bookkeeping exception on top of whatever the caller was already reporting, which is the +same mistake as the strict `RollbackAsync` above. `IUnitOfWorkScope.DisposeAsync` goes +through `FailAsync` for that reason: a frame that ends unresolved has failed, and it has +failed in exactly the way `FailAsync` already handles. + +**"Frames, not savepoints" describes *this* mechanism, not the connection.** A joiner +issues no SQL, and the depth counter is in-process. EF Core is separate and unaffected by +it: its automatic-savepoint feature issues a real `SAVEPOINT` / `RELEASE SAVEPOINT` on the +ambient connection around **every** `SaveChangesAsync` that runs inside an externally +supplied transaction, at any frame depth, and nothing here turns that off. It is the +behaviour we want — a failed `SaveChanges` rolls back to its savepoint and leaves the +ambient transaction usable — and it is recorded because a reader of § Nesting alone would +conclude no nested SQL exists. + None of this changes what the ADR decides — one connection per scope, owned by `IUnitOfWork`, with every context and cross-cutting writer enlisted on it. From 58bbafacf7272086308b4f4d98fdd71cd6ca4f55 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 15:39:17 +0300 Subject: [PATCH 21/35] docs(roadmap): close Packet 6 with the record of what it got wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nineteen commits, six implementation steps, twelve review rounds. The Status block, the sequence entry, README and CLAUDE.md now say Packet 6 shipped and Packet 7 is next; the delivery record says what that cost. Four things in it are worth a reader's time more than the deliverables list. The migration this packet exists to ship could not be applied by the one documented path, and no test could see it — the fixture calls `Database.MigrateAsync()` directly. A structural sweep is only as wide as the schema it runs on: the assertions were rewritten from a hand-written table list to a catalogue enumeration and still ran on one of the two migration chains, so a second permissive policy on `outbox_messages` passed the entire suite. Tests that agreed with the code instead of constraining it — the lesson Packet 5's record already carried — showed up again in a different shape: an owner-denial case asserting zero rows against a table nothing populated, and two restrictive policies that could both be deleted with the suite green. And the transaction boundary was wrong in the two places it is hardest to see, both of them only reachable by a test fake that modelled nesting depth and a failing terminal call, which the first one did not. Tenancy is now the only module holding domain code, and CLAUDE.md says so rather than repeating that every module assembly is empty. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 39 +++-- README.md | 21 ++- docs/roadmap/phase-02a-kernel-tenancy.md | 177 ++++++++++++++++++++++- 3 files changed, 216 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d2326a15..70d28393 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,11 +36,11 @@ repository holds only LearnStack's side of the boundary, in **Phase 01 complete. [Phase 02a](docs/roadmap/phase-02a-kernel-tenancy.md) in progress — -packets 0–3, 3b, 4 and 5 shipped; packets 4–10 were re-scoped on 2026-08-08 +packets 0–3, 3b, 4, 5 and 6 shipped; packets 4–10 were re-scoped on 2026-08-08 after a four-report audit of the corpus. -[Packet 6](docs/roadmap/phase-02a-kernel-tenancy.md#packet-sequence) — the -tenancy schema and the first migration written against the corrected RLS -template — is next.** +[Packet 7](docs/roadmap/phase-02a-kernel-tenancy.md#packet-sequence) — host and +tenant resolution, the EF query filters, the request-level isolation suite and +the two seed tenants — is next.** **Phase 01** shipped the .NET 10 solution scaffold under `backend/` (core + 7 modules × 4 projects + 4 test projects including the @@ -109,6 +109,24 @@ is long because several of its defects were introduced by the *fix* for an earlier one, and because three tests were caught agreeing with the code instead of constraining it — the packet's most repeated lesson. +**Packet 6** shipped the tenancy schema: the four database roles, ten tables in +two independent migration chains, and every one of them under `ENABLE` **and** +`FORCE ROW LEVEL SECURITY` with the corrected +[ADR-0003 Amendment 3](docs/decisions/0003-tenant-isolation-defense-in-depth.md) +template — one `AND`-ed policy per table, in the four table classes. With them: +`TenantId` / `OrganizationId` as Vogen value objects, the `Tenant` and +`Organization` aggregates, the repository's first module spec at +[docs/modules/tenancy/](docs/modules/tenancy/README.md), and +[ADR-0040](docs/decisions/0040-ambient-unit-of-work.md)'s ambient unit of work — +one connection per scope, every module `DbContext` enlisted on it, and +`TransactionBehavior`'s body replacing the Packet 3 shell. Its record, +[Delivery Record (Packet 6)](docs/roadmap/phase-02a-kernel-tenancy.md#delivery-record-packet-6), +is long for the reason Packet 5's was: the packet's own review rounds found that +`make migrate` could not apply the migration the packet exists to ship, that a +structural sweep is only as wide as the schema it runs on — a second permissive +policy on `outbox_messages` passed the whole suite — and that the transaction +boundary was wrong in the two places it is hardest to see. + **The 2026-08-08 restructure** re-scoped packets 3b–10 along three lines, all recorded in the Phase 02a Status block: @@ -135,11 +153,14 @@ is the next user-visible milestone** — the first phase whose output someone who does not read C# can evaluate: two hosts, two tenants, two education sites, one binary and one database. -Every module assembly is still empty of domain code. Module-level -references in the docs (e.g. `LearnStack.Modules.Education.Application`, -`ILiveClassProvider`, `ITenantSearch`) describe **intended** shape that -the corpus anchors against; Phase 02a packets 6–9 and Phase 02d are -where the first of those types actually land. +**Tenancy is the only module holding domain code**, as of Packet 6: the +`Tenant` and `Organization` aggregates, their entities, and +`TenancyDbContext`. The other six module assemblies are still empty, and +module-level references in the docs (e.g. +`LearnStack.Modules.Education.Application`, `ILiveClassProvider`, +`ITenantSearch`) describe **intended** shape that the corpus anchors +against; Phase 02a packets 7–9 and Phase 02d are where the first of those +types actually land. ## Where to start diff --git a/README.md b/README.md index 9287f6bd..afe4a171 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,9 @@ for LearnStack's side of the boundary. ## Status **Phase 01 complete. [Phase 02a](docs/roadmap/phase-02a-kernel-tenancy.md) in progress — -packets 0–3, 3b, 4 and 5 shipped; packets 4–10 re-scoped on 2026-08-08. -[Packet 6](docs/roadmap/phase-02a-kernel-tenancy.md#packet-sequence) — the tenancy -schema and the corrected RLS template — is next.** +packets 0–3, 3b, 4, 5 and 6 shipped; packets 4–10 re-scoped on 2026-08-08. +[Packet 7](docs/roadmap/phase-02a-kernel-tenancy.md#packet-sequence) — host and tenant +resolution, the query filters, and the two seed tenants — is next.** Phase 01 shipped the .NET 10 solution scaffold, the `pnpm` frontend monorepo (`apps/web` + `packages/{config,ui,sdk}`), the local-dev `docker-compose` stack, and the @@ -69,9 +69,15 @@ idempotency keys and ETag concurrency, and the first working SDK generation. foundation ports with the implementations that actually run today — `ICacheService` / `InMemoryCacheService`, `IEventBus` / `InProcessEventBus`, `ISecretProvider` / `ConfigurationSecretProvider` — each selected at one composition-root site, and moved -the seven demand-gated services out of the daily `make dev` loop. Both records list the -defects the packet introduced and caught in its own review rounds alongside what it -built. +the seven demand-gated services out of the daily `make dev` loop. +[Packet 6](docs/roadmap/phase-02a-kernel-tenancy.md#delivery-record-packet-6) shipped the +tenancy schema — the four database roles, ten tables in two migration chains, every one +under `ENABLE` **and** `FORCE ROW LEVEL SECURITY` with the corrected +[ADR-0003 Amendment 3](docs/decisions/0003-tenant-isolation-defense-in-depth.md) +template — together with the `Tenant` and `Organization` aggregates, the first module +spec, and [ADR-0040](docs/decisions/0040-ambient-unit-of-work.md)'s ambient unit of work. +Each record lists the defects the packet introduced and caught in its own review rounds +alongside what it built. The 2026-08-08 restructure moved correctness earlier (the corrected RLS template in [ADR-0003 Amendment 3](docs/decisions/0003-tenant-isolation-defense-in-depth.md), durable @@ -80,7 +86,8 @@ additive infrastructure later behind its ports ([ADR-0035](docs/decisions/0035-demand-gated-infrastructure.md)), and moved the genericity proof earlier — two seed tenants in Packet 7, rendered in a browser in [Phase 02d](docs/roadmap/phase-02d-walking-skeleton.md), the next user-visible -milestone. Module assemblies hold no domain code yet. +milestone. Tenancy is the only module holding domain code; the other six assemblies are +still empty. ```bash make install # one-time: deps + git hooks diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index ce66957e..54d9f053 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -1,6 +1,6 @@ # Phase 02a: Platform Kernel, Multi-Tenancy, Organization, and Foundation Sockets -> **Status (2026-08-27).** Phase 02a in progress. Packets 0–3, 3b, 4 and 5 shipped; the +> **Status (2026-08-28).** Phase 02a in progress. Packets 0–3, 3b, 4, 5 and 6 shipped; the > 2026-08-08 restructure re-scoped packets 4–10 and added packet 3b. Each packet > is independently reviewable in its own commit, matching the > [Phase 01 cadence](phase-01-repository-tooling.md). The order is dependency-driven: a @@ -15,7 +15,7 @@ > | 3b | Decision repair | ✅ [record](#delivery-record-packet-3b) | > | 4 | API conventions | ✅ [record](#delivery-record-packet-4) | > | 5 | Foundation ports and default implementations | ✅ [record](#delivery-record-packet-5) | -> | 6 | Tenancy schema and the corrected RLS template | ⏳ [scope](#packet-sequence) | +> | 6 | Tenancy schema and the corrected RLS template | ✅ [record](#delivery-record-packet-6) | > | 7 | Tenant and organization resolution, isolation, two tenants | ⏳ [scope](#packet-sequence) | > | 8 | Tenant Customization foundation | ⏳ [scope](#packet-sequence) | > | 9 | Audit infrastructure and the entitlement socket | ⏳ [scope](#packet-sequence) | @@ -28,8 +28,9 @@ > [`## Delivery Record (Packets 0–3)`](#delivery-record-packets-03) and are not > rewritten. Packet 3b has its own record in > [`## Delivery Record (Packet 3b)`](#delivery-record-packet-3b), and Packet 4 in -> [`## Delivery Record (Packet 4)`](#delivery-record-packet-4), and Packet 5 in -> [`## Delivery Record (Packet 5)`](#delivery-record-packet-5) — each kept separate +> [`## Delivery Record (Packet 4)`](#delivery-record-packet-4), Packet 5 in +> [`## Delivery Record (Packet 5)`](#delivery-record-packet-5), and Packet 6 in +> [`## Delivery Record (Packet 6)`](#delivery-record-packet-6) — each kept separate > because the frozen one is scoped to packets 0–3.** ## Goal @@ -342,7 +343,8 @@ They are demand-gated with written triggers in stack moves them behind a non-default profile so the daily loop runs the services the backend can actually call. -**Packet 6 — Tenancy schema and the corrected RLS template ⏳** +**Packet 6 — Tenancy schema and the corrected RLS template ✅** +([delivery record](#delivery-record-packet-6)) Migrations and EF configurations for `tenants`, `organizations` (per [ADR-0017](../decisions/0017-tenant-organization-hierarchy.md)), `tenant_domains`, `tenant_locales` (per @@ -2118,3 +2120,168 @@ one. > names the superseded `hub:host:{host}` key. Accepted ADR bodies are immutable, > the superseding records already govern both points, and a dated Amendment for a > diagram would cost more than the drift does. + +## Delivery Record (Packet 6) + +Kept separate from the records above for the reason they are separate from each +other: each is scoped to its own packets and is not rewritten. This one records +what Packet 6 shipped across six steps, and — like Packets 4 and 5 — what its own +plan and its own first drafts had wrong. Almost every defect below was introduced +by this packet and found by its own review rounds, which is the only reason it is +in a record rather than in production. + +> **Packet 6 — Tenancy schema and the corrected RLS template ✅** +> +> Nineteen commits: six implementation steps, each followed by an Opus and a +> Sonnet review round whose confirmed findings were fixed and committed before the +> next step began. +> +> ### What shipped +> +> **The decisions the packet needed and did not have.** +> [ADR-0039](../decisions/0039-optimistic-concurrency-token.md) fixes the +> concurrency token at `row_version bigint` / CLR `long`, incremented in +> `AuditableEntity` rather than by an interceptor; three documents had answered +> that question three ways, one of them offering `xmin`. +> [ADR-0040](../decisions/0040-ambient-unit-of-work.md) fixes the unit of work at +> one connection per scope; three documents named `IUnitOfWork` and none said what +> it wrapped. +> +> **The four database roles**, provisioned by the same script the compose stack +> runs and the integration fixture reads: `learnstack_migration` owns every table +> and is `NOBYPASSRLS`, `learnstack_app` connects at runtime, and +> `learnstack_platform` and `learnstack_outbox_admin` hold the two audited +> bypasses. Four connection strings, and `make migrate` as the only carrier of the +> migration credential. +> +> **Ten tables in two migration chains.** Eight in the Tenancy module's, two — the +> ones no module owns — in the platform's, on its own history table so neither +> chain can block the other. Every one carries `ENABLE` **and** `FORCE ROW LEVEL +> SECURITY` and one `AND`-ed policy per table, per +> [ADR-0003 Amendment 3](../decisions/0003-tenant-isolation-defense-in-depth.md): +> the self-keyed class for `tenants`, the tenant-wide class for six, the full +> org-scoped template with its `app.scope` read hatch and two `AS RESTRICTIVE` +> write guards for `tenant_settings`, and the role-qualified platform-scoped class +> for `platform_host_to_tenant`, which is read *before* a tenant is known. +> +> **The ambient unit of work.** `IUnitOfWork` / `IUnitOfWorkScope` in the +> SharedKernel, `NpgsqlUnitOfWork` in Infrastructure, the shared +> `AddModuleDbContext` helper, `TenancyDbContext` as its first consumer, and the +> `TransactionBehavior` body replacing the Packet 3 shell. The application data +> source is guarded twice against a credential that would make every policy inert. +> +> ### What the packet got wrong, and what caught it +> +> **`make migrate` could not apply the migration this packet exists to ship.** +> `dotnet ef` resolves the design-time package from the **startup** project, and +> the recipe names `LearnStack.Api`, which did not reference it. The tool refused +> before opening a connection — and the suite stayed green, because the +> Testcontainers fixture calls `Database.MigrateAsync()` directly. The recipe also +> never exported the value it read from `.env` (EF applies `--connection` *after* +> the design-time factory returns, so the factory threw first) and walked only +> `src/Modules`, leaving the platform chain unmigrated. Three faults on the one +> documented path, none of which any test could see. +> +> **A sweep is only as wide as the schema it runs on.** The structural +> assertions — row security, the permissive-policy rule, snake_case, the grant +> matrix — were rewritten from a hand-written eight-name list to a catalogue +> enumeration, and then still ran on a fixture carrying one of the two chains. So +> "every table" meant eight of ten. Measured: a second permissive `SELECT` policy +> on `outbox_messages` passed the entire suite while letting any session with any +> tenant context read every tenant's pending events, and +> `GRANT UPDATE ON outbox_messages TO learnstack_app` let a handler mark every +> pending row processed — making each event permanently undeliverable — with every +> assertion still green. +> +> **Tests that agreed with the code instead of constraining it**, the packet's +> most repeated lesson and the one Packet 5's record already carried. +> `TheOwnerIsDeniedOnThePlatformScopedTable` asserted `count(*) = 0` on a table the +> fixture never populated: it passed with every policy dropped and row security +> disabled. Five of the eight tenancy tables held no rows at all. The two +> `AS RESTRICTIVE` guards on `tenant_settings` could both be deleted with the suite +> green, because no test ever set `app.scope = 'tenant'` — and under any ordinary +> session the base policy's own organization term refuses the sibling row first. +> The idempotency assertions pinned constraint *names* rather than bounds, so a cap +> of zero passed. +> +> **The transaction boundary was wrong in the two places it is hardest to see.** +> `CommitAsync` resolved its frame before the `COMMIT` round trip, so a faulted +> commit left no frame and the behavior's rollback threw over the database's own +> exception — and because the replacement is not an `OperationCanceledException`, a +> client disconnecting mid-commit was audited as a failure, captured, and answered +> `500` instead of `499`. Separately, an inner `Result.Fail` that an outer handler +> absorbed poisoned the whole unit, which ADR-0040 § Nesting forbids in as many +> words; measured through the real behavior against a real database, the outer +> handler lost its committed row and got an exception in place of its success. +> Neither was reachable by the tests as written: the fake unit of work modelled +> neither nesting depth nor a failing terminal call. +> +> **`ConnectionStrings:Default` was accepted whatever role it named.** Two +> paragraphs of remarks argued for `learnstack_app` and the factory built a data +> source from anything. Either `BYPASSRLS` role — they sit two and three lines away +> in `.env.example` — turns the fail-closed state this packet ships in from "no +> rows" into "every tenant's rows". +> +> **Schema defects the reviews measured.** `ux_tenant_domains_host` was table-wide, +> so a soft-deleted claim held a hostname against every other tenant forever, +> across a boundary RLS otherwise hides. `Down()` reversed nothing — it aborted on +> `DROP FUNCTION` and would have aborted again on `DROP TABLE organizations`. +> "NULLS NOT DISTINCT, which EF cannot express" was false on the pinned packages, +> and the raw-SQL workaround left an index in the snapshot against a constraint in +> the database. `row_version` carried `HasDefaultValue(0L)` alone, which sets +> `ValueGenerated = OnAdd` — benign, and rejected by the rule this packet +> registered. Two foreign keys had no supporting index. Nothing tied +> `idempotency_keys.state` to its four response columns, so a `completed` row with +> no body would have been reported as replayable. +> +> ### What the corpus owed, and now carries +> +> Four amendments, all correcting a document against a measurement rather than +> changing a decision: [ADR-0039](../decisions/0039-optimistic-concurrency-token.md) +> Amendment 1 (the two forbidden EF calls) and Amendment 2 (the chain is three +> calls, not one, because `HasDefaultValue` sets `ValueGenerated`); +> [ADR-0037](../decisions/0037-idempotency-key-contract.md) Amendment 3 +> (`claimed_at` on reclaim, and the outcome CHECK); and +> [ADR-0040](../decisions/0040-ambient-unit-of-work.md) Amendment 2 (the handle's +> shape as shipped, and why `FailAsync` collapses silently where `CompleteAsync` +> throws). [ADR-0031](../decisions/0031-postgresql-major-version.md) Amendment 1 +> corrected `gen_uuid_v7()` to `uuidv7()` in six documents — the function the first +> name refers to does not exist, so every insert would have failed. +> +> The glossary classed `tenants` as a table living above tenants and called RLS +> "(later)", which the shipped schema falsifies twice over. `12-localization.md` +> and `21-feature-flags.md` were reconciled with what the migration transcribes. +> [docs/modules/tenancy/](../modules/tenancy/README.md) is the repository's first +> module spec, carrying all ten Standards 13 sections. +> +> Nine architecture rules moved from **Registered** to **Implemented**, and the +> catalogue rows say so with the file that carries them: +> `Aggregates_With_Optimistic_Concurrency_Map_RowVersion`, +> `Organization_Aggregate_Declared_In_Tenancy_Domain`, +> `TenantWide_Row_Of_TenantB_Is_Invisible_To_TenantA`, +> `Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck`, +> `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork`, +> `TransactionBehavior_Does_Not_Reference_A_Module_Assembly`, and three the packet +> registered as it went — `Migration_Startup_Project_References_EntityFrameworkCore_Design`, +> `Migrate_Target_Covers_Every_Migration_Chain` and +> `Every_Foreign_Key_Has_A_Supporting_Index`, the last of which found two real gaps +> on its first run. +> +> ### What Packet 6 deliberately did not do +> +> `IHostToTenantResolver`, `TenantResolverMiddleware`, the EF query filters and the +> request-level isolation suite are Packet 7 — the schema-level cases ship here +> because the migration's own assertions needed the two-tenant seed anyway. +> `app.scope` has no carrier: `ITenantContext` exposes no scope member, so no +> application path sets it and the cross-organization read hatch is unreachable at +> runtime, which is the correct default. `IAuditStore.WritePendingAsync` has its +> line reserved immediately before the commit and lands in Packet 9. The durable +> `IIdempotencyStore` is **not** here: ADR-0037 Amendment 1 separates the +> one-way-door table from the additive store, and the store ships on its ADR-0035 +> trigger. `Modules_Do_Not_Parallelize_Over_The_Ambient_Connection` is registered +> and awaits the first module code that could break it. +> +> Two things ADR-0040 says are not observable until Phase 03, recorded so a reader +> does not mistake a green suite for proof of them: a cross-module read inside the +> ambient transaction returning rows, and an outer failure after an inner write +> leaving zero rows in both modules. Both need a second module `DbContext`. From bc5ec44938407e6310ac2abeae9db730c81c7d07 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 16:47:48 +0300 Subject: [PATCH 22/35] fix(meta): close the pre-PR audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirty-eight findings from a whole-packet audit, four of them blockers. Three would have been visible only to the next person who touched the repository. **The required secret-scan check fails on this branch.** CI pins `cemililik/leakwatch@v1.5.0`; that version does not understand `leakwatch:ignore`, and the module renamed its path at v1.6.0, so the old path cannot be bumped — `@v1.8.0` on it does not build. Measured: v1.5.0 reports seven CRITICAL findings on a tree the local 1.8.0 scans clean, every one of them an inline-ignored test input. The hook and CONTRIBUTING pointed at the same dead path with `@latest`, which resolves to that same blind v1.5.0, so a developer following the documented install got a scanner that disagreed with the one gating their pull request. All three now name `HodeTech/leakwatch@v1.8.0`. **`add-tenant-owned-entity` taught a query filter that cannot work.** The snippet closed over an injected `tenantContext`, and EF constant-folds anything that is not a `DbContext` instance member into the cached model — so every request after the first answers with whichever tenant built the model. Under RLS that is a silent zero-rows outage. `ApplyConfigurationsFromAssembly` also silently skips a configuration with constructor arguments, so the shape could not have been reached anyway. Step 4 then told the reader the work was already covered by three convention tests that exist nowhere, and the pitfall list called the thing Step 2 requires a defect. That skill is what `implement-task` dispatches for the next tenant-owned table. **The `IHostToTenantResolver` reference body cannot run against what Packet 6 shipped.** It injected `TenancyDbContext` and opened a transaction on it — but the resolver runs before any tenant is known, and the shared registration helper throws by design there. ADR-0040 already puts every pre-transaction reader on its own short connection; the body now does that, and reads `platform_host_to_tenant` directly instead of a DbSet that is named differently. The rest is the corpus catching up with its own packet, and two developer-path gaps worth naming: the README quickstart never ran `make migrate`, so the front door ended at a database with zero tables; and nothing anywhere said how `ConnectionStrings:Default` reaches a host started with `dotnet run` — `.env` reaches Compose and `make migrate`, and neither hands it to the API. Three things became mechanical rather than prose. `Every_Database_Test_Carries_The_Docker_Trait`, because a mis-traited Docker test does not fail loudly — both CI jobs run on a runner with a Docker socket, so it passes in the wrong half and the Docker suite quietly stops being where Docker tests live, which is the opposite of what two comments claimed. The `meta` job now runs the commit-message check its own name has advertised since Phase 01. And the delivery record's counts were corrected where they overclaimed: three architecture rules were standing debt, not nine, and the other seven this packet both registered and implemented. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-backend-module/SKILL.md | 6 + .claude/skills/add-ef-migration/SKILL.md | 10 ++ .claude/skills/add-integration-test/SKILL.md | 50 ++++--- .../skills/add-tenant-owned-entity/SKILL.md | 122 ++++++++++++------ .claude/skills/local-dev-setup/SKILL.md | 30 ++++- .githooks/pre-commit | 6 +- .github/CONTRIBUTING.md | 20 ++- .github/workflows/ci.yml | 61 ++++++++- CLAUDE.md | 1 + README.md | 3 +- backend/Directory.Packages.props | 25 ++-- .../PersistenceConventionTests.cs | 44 +++++++ .../Database/PostgresFixture.cs | 14 +- .../Database/TenancySchemaTests.cs | 2 +- docs/architecture/09-tenant-isolation.md | 13 +- docs/architecture/27-custom-domain-tls.md | 46 ++++--- .../0039-optimistic-concurrency-token.md | 8 +- docs/glossary.md | 1 + docs/roadmap/README.md | 2 +- docs/roadmap/phase-02a-kernel-tenancy.md | 52 +++++--- docs/standards/05-database.md | 8 +- docs/standards/06-testing.md | 15 ++- docs/standards/11-security.md | 7 +- docs/standards/18-audit-coverage.md | 2 +- .../21-architecture-tests-catalogue.md | 29 ++++- docs/standards/README.md | 17 +-- scripts/seed.sh | 23 ++-- 27 files changed, 446 insertions(+), 171 deletions(-) diff --git a/.claude/skills/add-backend-module/SKILL.md b/.claude/skills/add-backend-module/SKILL.md index be3fb183..60be3b41 100644 --- a/.claude/skills/add-backend-module/SKILL.md +++ b/.claude/skills/add-backend-module/SKILL.md @@ -191,6 +191,12 @@ create the spec files under `docs/modules//`: Module migrations live with the module: +> `dotnet ef migrations add` needs `ConnectionStrings__Migration` exported into +> the process environment first — the design-time factory reads it and nothing +> else, and `--connection` does not satisfy it. See +> [add-ef-migration Step 1](../add-ef-migration/SKILL.md) for the one-line export; +> `make migrate` does the same thing for applying them. + ```bash # INTENT ONLY, snake_case: EF prepends the UTC timestamp, producing the # _ filename Standards 05 specifies. diff --git a/.claude/skills/add-ef-migration/SKILL.md b/.claude/skills/add-ef-migration/SKILL.md index f0a5b404..4fd311c1 100644 --- a/.claude/skills/add-ef-migration/SKILL.md +++ b/.claude/skills/add-ef-migration/SKILL.md @@ -50,6 +50,16 @@ and survive forward-only deploy rules ### Step 1: Generate the migration +```bash +# The design-time factory reads ConnectionStrings__Migration from the ENVIRONMENT +# and nothing else — `--connection` does not satisfy it, because EF consumes that +# option in its own parser and applies it only after the factory has returned. +# Read it out of .env the way the `migrate` target does, one key at a time: a +# connection string contains semicolons, so `. ./.env` parses them as statement +# separators, and .env.example single-quotes the value. +export ConnectionStrings__Migration=$(sed -n "s/^ConnectionStrings__Migration=//p" .env \ + | tail -1 | tr -d "\r" | sed "s/^['\"]//; s/['\"]$//") + ```bash # Pass the INTENT only, in snake_case. EF prepends the UTC timestamp itself, so # the file lands as _.cs — the format Standards 05 diff --git a/.claude/skills/add-integration-test/SKILL.md b/.claude/skills/add-integration-test/SKILL.md index 7fcc22dd..5e614df6 100644 --- a/.claude/skills/add-integration-test/SKILL.md +++ b/.claude/skills/add-integration-test/SKILL.md @@ -5,9 +5,10 @@ description: > `backend/tests/LearnStack.Tests.Integration` that exercises a real Postgres — connected as `learnstack_app`, never as the owner — and asserts behaviour under tenant + organization context. No Valkey and no Kafka: nothing the backend runs - calls them. The Postgres fixture and CI's `backend-integration` job both - arrived in Phase 02a Packet 6; the tenant-isolation suite and the seeded - tenants follow in Packet 7. Docker-bound cases carry + calls them. Phase 02a Packet 6 shipped the Postgres fixture, CI's + `backend-integration` job, both migration chains, the RLS policies, a + two-tenant seed and the schema-level isolation suite; Packet 7 re-runs those + cases through `TenantResolverMiddleware` and the EF query filters. Docker-bound cases carry `[Trait("Requires","Docker")]`, which is how CI routes them. USE FOR: cross-tenant / cross-org isolation tests (mandatory for every new `[TenantOwned]` / `[OrganizationScoped]` entity), outbox → consumer round trips, audit-pipeline assertions, RLS-effective-isolation tests. DO NOT USE FOR: @@ -56,28 +57,39 @@ architecture test) plus any other invariant the change touches. See ### Step 1: Reuse the fixture -> **The Postgres fixture exists** — `LearnStack.Tests.Integration.Database.PostgresFixture`, -> shipped by Phase 02a Packet 6 along with CI's `backend-integration` job. What -> does **not** exist yet is the tenant-isolation suite or any seeded tenant: the -> fixture provisions the four roles and hands out four connection strings, and -> Packet 7 adds the schema, the policies and the seed. The `AsTenant(...)` helper -> below is Packet 7's shape, not today's. +> **Two fixtures, and picking the wrong one is the common mistake.** +> `PostgresFixture` is the container plus the four roles and nothing else — use it +> for a role-level or provisioning question. `SchemaFixture` builds on it and is +> what almost every test wants: both migration chains applied and **every one of +> the ten tables seeded for two tenants**, with a second organization under tenant +> A. Share it with `[Collection(SharedSchema.Name)]` rather than +> `IClassFixture<>`, so one container serves the whole schema suite. > -> Anything using it carries `[Trait(RequiresDocker.Key, RequiresDocker.Value)]`, +> Anything touching either carries `[Trait(RequiresDocker.Key, RequiresDocker.Value)]`, > which is how CI routes it to `backend-integration` rather than `backend`. -What the fixture does today: +What the fixtures do today: -- Spins **Postgres only** via Testcontainers. Not Valkey, not Kafka — nothing the +- Spin **Postgres only** via Testcontainers. Not Valkey, not Kafka — nothing the backend runs today calls either, and both sit behind the `gated` compose profile per [ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md). -- Provisions the **four database roles** before the first migration, then applies - migrations **as `learnstack_migration`** — which owns every table — and exposes - a connection as **`learnstack_app`** for the tests themselves. A test that - connects as the owner or as a `BYPASSRLS` role passes even when every policy is - inert, so it proves nothing. -- Seeds a baseline platform admin, two tenants, two orgs per tenant. -- Exposes `fixture.AsTenant(tenantId, organizationId?)` to scope a block. +- Provision the **four database roles** by running the same script the compose + stack runs, then apply both migration chains **as `learnstack_migration`** — + which owns every table — and expose a connection as **`learnstack_app`** for the + tests themselves. A test that connects as the owner or as a `BYPASSRLS` role + passes even when every policy is inert, so it proves nothing. +- Seed **every table for both tenants**, deliberately: a count assertion against a + table the fixture never populated passes whatever the policy says. That shipped + once, in Packet 6, and is why `SchemaFixture` fills all ten. +- Expose the seeded ids as `SchemaFixture.TenantA` / `TenantB` / `OrgA1` / `OrgA2`, + and the session-variable helpers as `SchemaQueries.SetTenantAsync` / + `SetSettingAsync` — `set_config(name, value, true)`, not `SET LOCAL`, because + PostgreSQL's `SET` takes no bind parameter. + +There is no `AsTenant(...)` helper. Scope a block by opening a transaction and +calling `SchemaQueries.SetTenantAsync(connection, transaction, tenantId)` as its +first statement, which is what the shipped suite does and what +`IUnitOfWork.SetTenantContextAsync` does in production. ```csharp public sealed class EnrollmentCreateTests : IClassFixture diff --git a/.claude/skills/add-tenant-owned-entity/SKILL.md b/.claude/skills/add-tenant-owned-entity/SKILL.md index 747ece70..078990b7 100644 --- a/.claude/skills/add-tenant-owned-entity/SKILL.md +++ b/.claude/skills/add-tenant-owned-entity/SKILL.md @@ -144,42 +144,57 @@ public sealed class Configuration : IEntityTypeConfiguration<> } ``` -**Write the query filter here, in this configuration.** There is no -`TenantQueryFilterConvention` and no `OrganizationQueryFilterConvention` — neither -type has ever existed in this repository, and an earlier version of this file -described both as the only legal source, which sent implementers looking for -something to import: - -```csharp -// Tenant-owned: the filter mirrors the RLS predicate. Both layers, always — -// the filter is what makes an ordinary LINQ query safe, the policy is what -// makes it safe when the filter is bypassed. -builder.HasQueryFilter(x => x.TenantId == tenantContext.TenantId); - -// Org-scoped ADD the organization term to the SAME filter — a second -// HasQueryFilter call REPLACES the first rather than combining with it: -builder.HasQueryFilter(x => - x.TenantId == tenantContext.TenantId - && (x.OrganizationId == null - || x.OrganizationId == tenantContext.OrganizationId)); - -// Soft-deletable aggregates AND the deleted term into the same expression too, -// and gate on DeletedAt rather than the computed IsDeleted property, which EF -// cannot translate. -``` +**The query filter is not written here, and it is not written by a convention +either.** There is no `TenantQueryFilterConvention` and no +`OrganizationQueryFilterConvention` — neither type has ever existed in this +repository, and an earlier version of this file described both as the only legal +source, which sent implementers looking for something to import. A later version +over-corrected and put a snippet in this step; that snippet was **wrong in a way +that cannot be seen locally**, and the two facts behind it are what you need: + +- **A query filter's closure root must be a `DbContext` instance member.** EF + builds the model once and caches it. Anything else the lambda closes over — + an `ITenantContext` injected into the configuration, a local, a field on the + configuration — is constant-folded into the cached model as a SQL literal, so + every later request answers with whichever tenant happened to build the model + first. Measured in this repository: two contexts, two tenants, and request B + emitted `WHERE t."TenantId" = '1111…'` — tenant A's id, baked in. Under RLS + that is a silent zero-rows outage rather than a leak, which is worse to + diagnose, not better. +- **`ApplyConfigurationsFromAssembly` silently skips a configuration that has + constructor arguments.** No exception, no log: the entity is simply mapped by + convention, with no filter at all. `TenancyDbContext` uses that call, so a + `ThingConfiguration(ITenantContext ctx)` disappears rather than failing. + +Together those rule out the obvious shape, which is why **Phase 02a Packet 7 +owns the filters** — it lands `TenantResolverMiddleware`, the value the filter +reads, and the two rules below, together. Do not invent a filter here ahead of +it; if your entity ships before Packet 7, say so in the PR and rely on RLS, +which is live from the migration that creates the table. `Every_TenantOwned_Entity_HasFilterAndRlsPolicy` is what will make a forgotten filter fail, and `Every_OrgScoped_Entity_HasOrgIdAndFilter` covers the -organization term. Both are **registered and not yet implemented** — Phase 02a -Packet 7 introduces them, so until then a forgotten filter is caught by review -only. Check the -[catalogue](../../../docs/standards/21-architecture-tests-catalogue.md) rather -than assuming a net is under you. +organization term. Both are **registered and not yet implemented** — Packet 7 +introduces them, so until then a forgotten filter is caught by review only. Check +the [catalogue](../../../docs/standards/21-architecture-tests-catalogue.md) +rather than assuming a net is under you. + +One thing that is true whenever the filter does land: a second `HasQueryFilter` +call **replaces** the first rather than combining with it, so the tenant term, +the organization term and the soft-delete term go into one expression — and the +soft-delete term gates on `DeletedAt`, not the computed `IsDeleted` property, +which EF cannot translate. ### Step 3: Migration — schema + RLS Generate the migration: +> `dotnet ef migrations add` needs `ConnectionStrings__Migration` exported into +> the process environment first — the design-time factory reads it and nothing +> else, and `--connection` does not satisfy it. See +> [add-ef-migration Step 1](../add-ef-migration/SKILL.md) for the one-line export; +> `make migrate` does the same thing for applying them. + ```bash # EF prepends its own UTC timestamp, so pass the INTENT only. Passing a timestamp # too produces 20260827120000_20260827120000_add_.cs. @@ -302,16 +317,30 @@ migrations run as `learnstack_migration`, which owns the table. Integration test this entity must connect as `learnstack_app` — a test that connects as the owner passes against an inert policy and proves nothing. -### Step 4: Architecture test (already covered by convention) - -The conventions tests catch missing pieces automatically: - -- `Every_TenantOwned_Entity_Has_TenantId` — the marker + property combo. -- `Every_TenantOwned_Table_HasRls_With_AppTenantId` — the migration's RLS policy. -- `Every_OrgScoped_Entity_HasOrgIdAndFilter` — the marker + nullable property + RLS. - -If you're adding a *new* marker attribute or a *new* isolation pattern, write a new -architecture test (see [add-architecture-test](../add-architecture-test/SKILL.md)). +### Step 4: Architecture test (registered, not yet implemented) + +**Nothing catches a missing filter automatically today.** An earlier version of +this step said the opposite and named three rules — +`Every_TenantOwned_Entity_Has_TenantId`, +`Every_TenantOwned_Table_HasRls_With_AppTenantId` and +`Every_OrgScoped_Entity_HasOrgIdAndFilter` — of which the first two exist nowhere +in the catalogue or the test tree. A developer who reached this step was told the +work was done. + +The two rules the catalogue actually registers for this surface are +`Every_TenantOwned_Entity_HasFilterAndRlsPolicy` and +`Every_OrgScoped_Entity_HasOrgIdAndFilter`, both **Registered** and owned by +Phase 02a Packet 7. What *is* implemented and will catch part of this today: +`Every_Foreign_Key_Has_A_Supporting_Index` and the schema sweeps in +`TenancySchemaTests` — row security enabled *and* forced on every table in the +catalogue, no second permissive policy for one command, snake_case identifiers, +and the exact grant matrix. Those run against the applied schema, so they cover +your migration the moment it lands. + +Until Packet 7, the isolation test in Step 5 is the net, and it is the only one. +If you're adding a *new* marker attribute or a *new* isolation pattern, write a +new architecture test (see +[add-architecture-test](../add-architecture-test/SKILL.md)). ### Step 5: Integration test — the isolation pair @@ -360,10 +389,17 @@ See [add-integration-test](../add-integration-test/SKILL.md). tenant-wide rows. The default is `nullable + tenant-wide allowed`. - **`Entity` instead of `AuditableEntity<Id>`.** You lose `created_at` / `updated_at` automation. Only the audit aggregate uses `Entity`. -- **Manual EF query filter in `Configure(...)`.** The convention adds the filter; - doing it twice creates an `AND` of two filters and breaks platform-admin reads. -- **No isolation test.** The architecture tests catch the RLS *policy* but not the - semantic correctness. An explicit `TenantA_cannot_read_TenantB` test is the only - safety net for a buggy migration. +- **A query filter closing over anything but a `DbContext` instance member.** + EF caches the model, so the value is baked in as a literal and every later + request answers with the first tenant that built it. Under RLS that is a silent + zero-rows outage. See Step 2 — and note there is no convention adding a filter + for you, in either direction. +- **No isolation test.** The schema sweeps catch a missing or mis-shaped *policy*; + they cannot catch a policy that is well-formed and wrong. An explicit + `TenantA_cannot_read_TenantB` test — connecting as `learnstack_app`, against a + fixture that seeds **both** tenants — is the only safety net for that. A count + assertion against a table the fixture never populated passes whatever the policy + says; that shipped once in Packet 6 and is the reason `SchemaFixture` fills + every table it asserts on. - **Forgetting `using x.AsTenant(...)` in tests.** Without it, the connection has no `app.tenant_id` set and queries see nothing — which can mask a missing filter. diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index 6b01ef38..9d405b5b 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -98,12 +98,40 @@ the API or the web app, and it does not run migrations or seeds; those are separate commands you run yourself: ```bash -make migrate # apply every module's EF migrations +make migrate # apply both migration chains dotnet run --project backend/src/LearnStack.Api # API on 5080 pnpm --filter @learnstack/web dev # web on 3000 make seed # health gate + demo credentials ``` +**`dotnet run` needs `ConnectionStrings:Default`, and nothing hands it over.** +`.env` reaches Compose (through `--env-file`) and `make migrate` (which reads it +key by key), but not a host you start yourself: there is no `ConnectionStrings` +section in `appsettings*.json` and no `.env` loader in `backend/src`. Since Packet +6 the composition root builds the application data source from that key, so +without it the first request that touches the database fails with a message +naming it. Two ways to supply it, and the second survives a new shell: + +```bash +# Per shell. Read one key at a time — a connection string contains semicolons, +# so `. ./.env` parses them as statement separators, and .env.example quotes the +# value. +export ConnectionStrings__Default=$(sed -n "s/^ConnectionStrings__Default=//p" .env \ + | tail -1 | tr -d "\r" | sed "s/^['\"]//; s/['\"]$//") + +# Or once, into the user-secrets store the API project already declares +# (UserSecretsId learnstack-api-dev) — kept outside the repository, so it cannot +# be committed: +dotnet user-secrets --project backend/src/LearnStack.Api \ + set "ConnectionStrings:Default" "$ConnectionStrings__Default" +``` + +The value names **`learnstack_app`** and the composition root refuses anything +else — by name, and then by asking the server whether the role it connected as +bypasses row security. Pointing it at `ConnectionStrings__Migration` or either +`BYPASSRLS` role makes every policy in the database inert, which is why it is +checked rather than assumed. + **`make migrate` runs as `learnstack_migration`, not as the API's role.** From Phase 02a Packet 6 the stack provisions four database roles on the first boot of a fresh `postgres-data` volume diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 9e50f173..424d909f 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -208,7 +208,7 @@ leakwatch_takes_files() { if command -v leakwatch >/dev/null 2>&1 && ! leakwatch_takes_files; then printf "pre-commit: this leakwatch build only scans directories — skipping the\n" >&2 printf " local secret scan (CI re-runs it on the whole tree).\n" >&2 - printf " upgrade: brew upgrade leakwatch | go install github.com/cemililik/leakwatch@latest\n" >&2 + printf " upgrade: brew upgrade leakwatch | go install github.com/HodeTech/leakwatch@v1.8.0\n" >&2 elif command -v leakwatch >/dev/null 2>&1; then if [[ ${#all_staged[@]} -gt 0 ]]; then printf "pre-commit: leakwatch scan (%d file(s)) …\n" "${#all_staged[@]}" @@ -235,8 +235,8 @@ elif command -v leakwatch >/dev/null 2>&1; then fi else printf "pre-commit: leakwatch not on PATH — skipping local secret scan (CI re-runs it).\n" >&2 - printf " install: brew install cemililik/tap/leakwatch\n" >&2 - printf " or: go install github.com/cemililik/leakwatch@latest\n" >&2 + printf " install: brew install HodeTech/tap/leakwatch\n" >&2 + printf " or: go install github.com/HodeTech/leakwatch@v1.8.0\n" >&2 fi # ─── Backend: dotnet format ───────────────────────────────────────────── diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 30c4f8a8..41ce93ed 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -35,7 +35,7 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule - Required status checks (the job names from `.github/workflows/ci.yml`): - `backend (build + unit + arch + contract)` - `frontend (typecheck + lint + build + test)` - - `meta (commit hygiene + link audit)` + - `meta (compose + commit hygiene + link audit)` - `secret scan (leakwatch)` - `backend integration (Testcontainers)` — **activated in Phase 02a Packet 6** with the four-role provisioning suite, one packet earlier than planned: @@ -101,9 +101,15 @@ Per CLAUDE.md § Commit conventions: make install # one-time per clone: deps + git hooks make lint # dotnet format --verify + ESLint make typecheck # tsc --noEmit -make test # unit + arch + contract + vitest +make test # unit + arch + contract + integration + vitest ``` +`make test` starts Testcontainers since Phase 02a Packet 6, so it needs a Docker +socket and takes noticeably longer than it did. The Docker-bound cases are split +out by `[Trait("Requires","Docker")]`; to skip them, run +`dotnet test backend/LearnStack.slnx --filter "Requires!=Docker"`, which is +exactly what CI's `backend` job runs. + The pre-commit hook (activated by `make install`) runs, on staged files only: `dotnet format` on `*.cs`; prettier on JS / TS / JSON / Markdown **under `frontend/`**; `next lint --fix` on JS / TS under `frontend/apps/web` — the one @@ -136,11 +142,17 @@ at the repo root. Install once for the local pre-commit scan (CI runs it regardless, this is just earlier feedback): ```bash -brew install cemililik/tap/leakwatch # macOS (Homebrew) +brew install HodeTech/tap/leakwatch # macOS (Homebrew) # or: -go install github.com/cemililik/leakwatch@latest +go install github.com/HodeTech/leakwatch@v1.8.0 ``` +The version is pinned, and to the same one CI installs. `@latest` on the old +`cemililik/` path resolves to v1.5.0 — the module renamed its path at v1.6.0, so +nothing newer is installable there — and v1.5.0 does not understand +`leakwatch:ignore`, so a developer following an unpinned instruction would get a +scanner that disagrees with the one gating their pull request. + If Leakwatch flags an intentional dev credential, prefer: 1. **Inline ignore** at the literal — `# leakwatch:ignore` (or diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0712aa4d..2f44bec1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,11 +142,15 @@ jobs: # Testcontainers manages its own lifecycle and needs the daemon, not a # pre-declared service. # - # The filter is the exact complement of the `backend` job's. Every test in the - # solution runs in exactly one of the two; a trait value that is neither would - # run in nowhere, which is why the value is a constant - # (LearnStack.Tests.Integration.Database.RequiresDocker) rather than a string - # repeated at each call site. + # The filter is the exact complement of the `backend` job's, so every test in + # the solution runs in exactly one of the two — including a test whose trait + # value is mistyped, which `Requires!=Docker` matches and which therefore runs + # in `backend`. That is NOT a loud failure: this runner and that one both carry + # a Docker socket, so the mis-traited test starts its container, passes, and + # the Docker suite quietly stops being where the Docker tests live. The value + # is a constant (LearnStack.Tests.Integration.Database.RequiresDocker) to make + # the typo impossible, and `Every_Database_Test_Carries_The_Docker_Trait` + # catches a class that forgets the attribute altogether. timeout-minutes: 20 steps: - name: Checkout @@ -290,6 +294,45 @@ jobs: rm -f .env echo "Compose files validate in every profile projection." + - name: Commit hygiene (Conventional Commits, subject <= 72) + # The job has advertised "commit hygiene" in its name since Phase 01 and + # ran no such check. Measured on the branch that added this step: two of + # its own subjects were over the limit, at 77 and 81 characters, and + # nothing had said so — the only hook on disk is `.githooks/pre-commit`, + # which cannot see a message. + # + # Scoped to the pull request's own commits. On a push event there is no + # base to diff against, so the step reports and passes rather than + # re-judging history that is already merged. + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + fail=0 + # `-z` with `%s`: the subject alone, NUL-TERMINATED, because a subject + # can contain anything and because `%s%x00` leaves the record's trailing + # newline attached to the NEXT subject — measured, every length came back + # one too high and the two real violations reported 78 and 82. + while IFS= read -r -d '' subject; do + length=${#subject} + if [ "$length" -gt 72 ]; then + echo "::error::subject is ${length} chars (limit 72): ${subject}" + fail=1 + fi + if ! printf '%s' "$subject" \ + | grep -qE '^(feat|fix|docs|test|chore|refactor|perf|build|ci|revert)(\([a-z0-9.,: -]+\))?!?: .+'; then + echo "::error::subject is not Conventional Commits: ${subject}" + fail=1 + fi + done < <(git log -z --format='%s' "${BASE_SHA}..${HEAD_SHA}") + if [ "$fail" -ne 0 ]; then + echo "Standards 14 § Commit messages: type(scope): subject, imperative, <= 72 chars." + exit 1 + fi + echo "Every commit subject is Conventional Commits and within 72 characters." + - name: Markdown link audit (changed docs) # Template values from `github.event.*` are passed through `env:` so # they expand into shell variables AT THE SHELL'S quoting boundary, @@ -406,7 +449,13 @@ jobs: go-version: "1.25" - name: Install Leakwatch - run: go install github.com/cemililik/leakwatch@v1.5.0 + # HodeTech/, not cemililik/: the module renamed its path at v1.6.0, so the + # old path is only installable at v1.5.0 and earlier — and v1.5.0 does not + # understand `leakwatch:ignore`. Measured: the pinned v1.5.0 reports seven + # CRITICAL findings on a tree the local 1.8.0 scans clean, because every + # one of them is an inline-ignored test input. A required check that + # disagrees with the pre-commit hook is worse than no check. + run: go install github.com/HodeTech/leakwatch@v1.8.0 - name: Scan run: | diff --git a/CLAUDE.md b/CLAUDE.md index 70d28393..2c69fa72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,6 +207,7 @@ let the entry point pick it. | `docs/decisions/` | ADRs — one-time decisions with status, context, decision, consequences. Redirect / superseded ADRs live under `_redirects/`. | Accepted ADRs are immutable except for dated Amendments. | | `docs/standards/` | Engineering rules (`NN-topic.md`, 00 – 21). Each anchored standard carries a `**Derives from:** ADR-NNNN` header. | Editable as the team learns; standard changes cite an ADR. | | `docs/roadmap/` | Phased plan (`phase-NN-topic.md`, 00 – 12 with 02a/02b/02c/**02d**, 08a/08b/08c, and 09/09b splits). Every phase doc carries the same six sections — Goal, Scope, Deliverables, Completion Criteria, Risks, Phase Exit Decision — with three declared exceptions listed in [the roadmap index](docs/roadmap/README.md): Phase 09b and Phase 12 are pointer documents into the Hub repository, and Phase 01 predates the convention. | Editable per phase; the Status block of a shipped packet is a dated delivery record and is not rewritten. | +| `docs/modules/` | Per-module specifications (`/README.md` + `permissions.md` + `audit.md`), one directory per module, created with the first spec — [Tenancy](docs/modules/tenancy/README.md), Phase 02a Packet 6. The ten sections are fixed by [Documentation Standards](docs/standards/13-documentation.md). | Editable with the module. | | `docs/glossary.md` | Terminology source of truth. | Editable; new term goes here first, then used. | > `docs/analysis/` exists locally but is **gitignored** — it is a private scratchpad diff --git a/README.md b/README.md index afe4a171..311ff7f1 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,8 @@ still empty. ```bash make install # one-time: deps + git hooks -make dev # bring local stack up +make dev # bring local stack up (containers only — it creates no tables) +make migrate # apply the platform + module migration chains make seed # verify health + print demo credentials ``` diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 1a23169b..221b97d8 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -43,18 +43,23 @@ - + - + diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index e823604c..221da2de 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -238,6 +238,50 @@ private static string StripComments(string source) => source, @"//[^\n]*|/\*.*?\*/", string.Empty, System.Text.RegularExpressions.RegexOptions.Singleline); + [Fact] + public void Every_Database_Test_Carries_The_Docker_Trait() + { + // CI splits the integration assembly by `[Trait("Requires","Docker")]`, and + // the two filters are exact complements — so a class that forgets the + // attribute does not fail, it runs in the `backend` job. Both jobs are on + // ubuntu-latest, which carries a Docker socket natively, so it starts its + // container and passes: nothing goes red, and the Docker suite quietly + // stops being where the Docker tests live. + // + // Source-scanned rather than reflected, because this assembly does not + // reference LearnStack.Tests.Integration — and should not: a test project + // referencing another test project is a dependency nothing else in the + // repository has. + var directory = Path.Combine( + RepositoryPaths.RepoRoot(), "backend", "tests", + "LearnStack.Tests.Integration", "Database"); + + Directory.Exists(directory).Should().BeTrue( + "the Docker-bound suite lives there, and a rule that scans nothing passes"); + + var untraited = Directory + .EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories) + .Where(file => + { + var source = File.ReadAllText(file); + + // A file declaring no test method has nothing to trait — the + // fixtures and the shared query helpers are the case. + return (source.Contains("[Fact]", StringComparison.Ordinal) + || source.Contains("[Theory]", StringComparison.Ordinal)) + && !source.Contains( + "[Trait(RequiresDocker.Key, RequiresDocker.Value)]", + StringComparison.Ordinal); + }) + .Select(Path.GetFileName) + .Order(StringComparer.Ordinal) + .ToList(); + + untraited.Should().BeEmpty( + "every test class under Database/ needs a real Docker socket, and the " + + "trait is how CI routes it to the job that declares one"); + } + [Fact] public void Migrate_Target_Covers_Every_Migration_Chain() { diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs index f5177908..1780b270 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs @@ -23,12 +23,14 @@ namespace LearnStack.Tests.Integration.Database; /// went stale in the same commit that added a test. /// /// -/// The value is a constant because a mistyped one lands in the wrong job. -/// Measured: Requires!=Docker matches every test with no Requires -/// trait at all, so Requires=Dockerr would satisfy it and run in -/// backend, where there is no Docker daemon — a loud failure rather than a -/// silent skip, but a failure in the job that cannot fix it. One constant removes -/// the class. +/// The value is a constant because a mistyped one lands in the wrong job, +/// silently. Requires!=Docker matches every test whose value differs +/// — including Requires=Dockerr — so a mis-traited Docker test runs in +/// backend; and both jobs run on ubuntu-latest, which carries a +/// Docker socket natively, so it starts its container and passes. Nothing goes +/// red; the Docker suite just stops being where the Docker tests live. One +/// constant removes the typo, and +/// Every_Database_Test_Carries_The_Docker_Trait removes the omission. /// /// internal static class RequiresDocker diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs index 1b2604e3..0739bca6 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs @@ -438,7 +438,7 @@ AND a.attnum > 0 AND NOT a.attisdropped } [Fact] - public async Task EveryForeignKeyHasASupportingIndex() + public async Task Every_Foreign_Key_Has_A_Supporting_Index() { // Database Standards § Indexes: index every foreign key. Every foreign key // in this schema is ON DELETE RESTRICT, so every parent delete pays the diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index ad930c0d..ffb9b4d1 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -112,10 +112,15 @@ Three properties of that template matter to the isolation model described on thi - **An explicit `WITH CHECK`.** `USING` decides what is readable; `WITH CHECK` decides what is writable. -The `app.scope = 'tenant'` setting is issued by `TransactionBehavior`, alongside -`app.tenant_id` and `app.organization_id` and for the same reason — all three are -transaction-local, so middleware setting them would discard them before the guarded -query ran ([Security Standards § Tenant Context](../standards/11-security.md) is the +The `app.scope = 'tenant'` setting belongs with `app.tenant_id` and +`app.organization_id`, issued as the transaction's first statement for the same +reason — all three are transaction-local, so middleware setting them would discard +them before the guarded query ran. **Two of the three are issued today.** Packet 6 +ships `IUnitOfWork.SetTenantContextAsync`, which writes `app.tenant_id` and +`app.organization_id`; `ITenantContext` carries no scope member, so nothing sets +`app.scope` and the hatch below is unreachable at runtime — the correct default, +and [Packet 7](../roadmap/phase-02a-kernel-tenancy.md)'s to decide +([ADR-0040 Amendment 1](../decisions/0040-ambient-unit-of-work.md)) ([Security Standards § Tenant Context](../standards/11-security.md) is the single authority). What middleware contributes is the *input*: the request comes from a tenant-admin role carrying a tenant-wide operation flag (e.g. cross-org reporting), and the behavior turns that into the session variable. It diff --git a/docs/architecture/27-custom-domain-tls.md b/docs/architecture/27-custom-domain-tls.md index 7d146528..dbec28fc 100644 --- a/docs/architecture/27-custom-domain-tls.md +++ b/docs/architecture/27-custom-domain-tls.md @@ -222,9 +222,17 @@ public interface IHostToTenantResolver public sealed record HostResolution(TenantId TenantId, OrganizationId? OrganizationId); +// NpgsqlDataSource, NOT a module DbContext and NOT IUnitOfWork. This runs in +// TenantResolverMiddleware, before any transaction exists — and the shared +// registration helper throws by design when a module context is resolved outside +// the ambient transaction, because a context that never saw SET LOCAL reads zero +// rows from every tenant-owned table. ADR-0040 § Who sets app.tenant_id already +// puts every pre-transaction reader on "a short transaction of its own on its own +// connection"; this is that, and the resolver is the only setter of +// app.resolving_host. public sealed class CachedHostToTenantResolver( ICacheService cache, - TenancyDbContext db) : IHostToTenantResolver + NpgsqlDataSource dataSource) : IHostToTenantResolver { public Task ResolveAsync(string host, CancellationToken ct = default) => cache.GetOrSetAsync( @@ -240,7 +248,8 @@ public sealed class CachedHostToTenantResolver( // "WARNING: SET LOCAL can only be used in transaction blocks" and // has no effect, and a session-level set_config(..., false) would // survive on a pooled connection into the next request. - await using var tx = await db.Database.BeginTransactionAsync(token); + await using var connection = await dataSource.OpenConnectionAsync(token); + await using var tx = await connection.BeginTransactionAsync(token); // set_config(..., true) is SET LOCAL's function form and is // transaction-local for the same reason. It has to be this form: @@ -249,19 +258,26 @@ public sealed class CachedHostToTenantResolver( // spelling every other query uses is unavailable here, and string // interpolation into SET would be an injection site on the // anonymous page-load path. - await db.Database.ExecuteSqlAsync( - $"SELECT set_config('app.resolving_host', {host}, true)", token); - - var resolution = await db.HostMappings - .AsNoTracking() - // is_publicly_live, per ADR-0036 § HostOnly — NOT the - // Hub-side `is_active` in the payload sample below. A - // domain can be active (owned, verified) and not yet - // publicly live, and only the latter may answer an - // anonymous page load. - .Where(m => m.Host == host && m.IsPubliclyLive) - .Select(m => new HostResolution(m.TenantId, m.OrganizationId)) - .SingleOrDefaultAsync(token); + await using (var announce = new NpgsqlCommand( + "SELECT set_config('app.resolving_host', @host, true)", connection, tx)) + { + announce.Parameters.AddWithValue("host", host); + await announce.ExecuteNonQueryAsync(token); + } + + // is_publicly_live, per ADR-0036 § HostOnly — NOT the Hub-side + // `is_active` in the payload sample below. A domain can be active + // (owned, verified) and not yet publicly live, and only the latter + // may answer an anonymous page load. + await using var read = new NpgsqlCommand( + """ + SELECT tenant_id, organization_id + FROM platform_host_to_tenant + WHERE host = @host AND is_publicly_live + """, connection, tx); + read.Parameters.AddWithValue("host", host); + + var resolution = await ReadSingleAsync(read, token); await tx.CommitAsync(token); return resolution; diff --git a/docs/decisions/0039-optimistic-concurrency-token.md b/docs/decisions/0039-optimistic-concurrency-token.md index 21534e2b..3f754fd5 100644 --- a/docs/decisions/0039-optimistic-concurrency-token.md +++ b/docs/decisions/0039-optimistic-concurrency-token.md @@ -206,10 +206,12 @@ routing `SoftDelete` through the same stamp-and-increment primitive. ## Architecture Tests -Two rules. Neither exists yet; both are **Phase 02a Packet 6 deliverables**, and -registering them in +Two rules. **Both shipped in Phase 02a Packet 6** — the first as +`PersistenceConventionTests`, the second as a behavioural case in +`AuditableEntityTests` — and both are registered in [21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md) -is part of shipping them. Naming them here is a commitment, not a description. +with the file that carries them. What follows was a commitment when this ADR was +accepted and is now a description. - `Aggregates_With_Optimistic_Concurrency_Map_RowVersion` — every entity implementing `IOptimisticConcurrency` has its `Version` configured as the diff --git a/docs/glossary.md b/docs/glossary.md index c41a4600..1c5cc920 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -295,6 +295,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **`IHostToTenantResolver`** | Interface for host → `(tenant_id, organization_id?)` resolution. Reads `platform_host_to_tenant` and **nothing else** — never the Hub, because an anonymous page load must not depend on a control plane being reachable ([ADR-0034](decisions/0034-hub-contract-surface-invariant.md)). | | **APISIX** | The gateway in standalone YAML-reload mode per [ADR-0015](decisions/0015-api-gateway-apisix.md). The intended tenant-facing ingress, demand-gated to [Phase 11](roadmap/phase-11-production-hardening.md); until then ASP.NET middleware carries the same responsibilities in-process. `/api/internal/*` is never proxied by it — that listener is mTLS-only inside the pod. | | **`OutboxProcessor`** | The BackgroundService that **claims** batches of `outbox_messages` by writing a lease (`locked_by` / `locked_until`) under `FOR UPDATE SKIP LOCKED`, dispatches each through `IEventBus`, and handles retry / dead-letter. The lease is written to the row rather than held in the transaction, because `FOR UPDATE` locks end when the transaction ends. Delivery is **at-least-once**; consumer-side `IInboxGuard` is what makes duplicates safe. See [15-event-and-outbox.md](architecture/15-event-and-outbox.md). | +| **`IUnitOfWork`** | The ambient unit of work: **one database connection per scope**, and the transaction on it ([ADR-0040](decisions/0040-ambient-unit-of-work.md)). Every module `DbContext` resolved in the scope is built on that connection and enlisted in that transaction, and `IAuditStore` and `IOutbox` reach it through the same seam. The connection count is a correctness property, not a performance one: `SET LOCAL app.tenant_id` is connection- **and** transaction-local, so a context on its own connection reads zero rows from every tenant-owned table — silently. It exposes no `SaveChangesAsync`; contexts save themselves and it owns only the transaction boundary. | | **`IInboxGuard`** | The per-module inbox-deduplication helper. Every integration-event handler must call `IsAlreadyProcessedAsync` before business logic and `MarkAsProcessed` inside the same **transaction** as the business write — the ambient one the transport opens per delivery through `IUnitOfWork` ([ADR-0033](decisions/0033-audit-durability-model.md), [ADR-0040](decisions/0040-ambient-unit-of-work.md)). "The same `SaveChanges`" was the earlier formulation and ADR-0033 withdrew it. | | **`DeploymentMode`** | Enum (`Development \| SaaS \| Dedicated \| SelfHostedOnline \| SelfHostedAirGapped`) read at the composition root to select provider implementations. Modules never read this enum. | diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 8f4495f7..22bb9c22 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -42,7 +42,7 @@ not deferred to the showcase phase. - [Phase 00: Product Strategy and Architecture Definition](phase-00-product-architecture.md) — **complete** - [Phase 01: Repository, Tooling, and Local Infrastructure](phase-01-repository-tooling.md) — **complete** -- [Phase 02a: Platform Kernel, Multi-Tenancy, Organization, and Foundation Sockets](phase-02a-kernel-tenancy.md) — **in progress** (packets 0–3, 3b, 4 and 5 shipped; packet 6 next) +- [Phase 02a: Platform Kernel, Multi-Tenancy, Organization, and Foundation Sockets](phase-02a-kernel-tenancy.md) — **in progress** (packets 0–3, 3b, 4, 5 and 6 shipped; packet 7 next) - [Phase 02d: Two-Tenant Walking Skeleton](phase-02d-walking-skeleton.md) - [Phase 02b: Identity Integration, Session, and Events](phase-02b-events-auth.md) - [Phase 03: Identity Domain, Authorization, and Admin Foundation](phase-03-identity-admin.md) diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index 54d9f053..def6b1c0 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -352,10 +352,10 @@ Migrations and EF configurations for `tenants`, `organizations` (per nullable `organization_id` for org-scoped settings), `tenant_feature_flags` (tenant-flag level only — plan-level features arrive through the entitlement projection), `platform_entitlement_cache`, `platform_host_to_tenant`, `idempotency_keys` -(the durable `IIdempotencyStore` per -[ADR-0037](../decisions/0037-idempotency-key-contract.md); Packet 4 shipped the -port and an in-memory default that is correct for one instance and wrong for -two), and `outbox_messages`. Default-organization seeding at tenant creation. +(**the table only** — [ADR-0037](../decisions/0037-idempotency-key-contract.md) +Amendment 1 separates the one-way-door schema from the additive store, so +`InMemoryIdempotencyStore` stays the only registered `IIdempotencyStore` until +the store's ADR-0035 trigger fires), and `outbox_messages`. Default-organization seeding at tenant creation. **The system actor needs no seed, and this packet creates no `users` table.** `UserId.SystemActor` — the fixed id `00000000-0000-7000-8000-000000000001` in @@ -757,12 +757,16 @@ have to retrofit: - `platform_host_to_tenant` — host → `(tenant_id, organization_id?)` mapping (Hub-populated for SaaS / Dedicated, config-populated for SelfHosted; the table ships now). -- `idempotency_keys` — the durable `IIdempotencyStore` - ([ADR-0037](../decisions/0037-idempotency-key-contract.md)). Packet 4 ships the port - and an in-memory default that is correct for one instance and wrong for two; this is - the table that makes it survive a restart and a second instance. Each store call opens - its own short transaction and sets `app.tenant_id` as its first statement, because a - claim is taken **before** the MediatR `TransactionBehavior` that would otherwise do it. +- `idempotency_keys` — the table the durable `IIdempotencyStore` will need + ([ADR-0037](../decisions/0037-idempotency-key-contract.md)). **The table, not the + store:** Amendment 1 splits them, because the schema is a one-way door and the + implementation is additive. Packet 4 shipped the port and an in-memory default that + is correct for one instance and wrong for two, and that default stays registered + until the store's ADR-0035 trigger fires — the first `[Idempotent]` endpoint, or the + first deployment running more than one instance. When the store does arrive, each of + its calls opens its own short transaction and sets `app.tenant_id` as its first + statement, because a claim is taken **before** the MediatR `TransactionBehavior` that + would otherwise do it. - `outbox_messages` — the outbox table. Nothing dispatches from it until [Phase 02b](phase-02b-events-auth.md), but its schema and LearnStack's ownership of it are a one-way door — and that ownership is exactly what makes the dispatch @@ -2159,7 +2163,7 @@ in a record rather than in production. > chain can block the other. Every one carries `ENABLE` **and** `FORCE ROW LEVEL > SECURITY` and one `AND`-ed policy per table, per > [ADR-0003 Amendment 3](../decisions/0003-tenant-isolation-defense-in-depth.md): -> the self-keyed class for `tenants`, the tenant-wide class for six, the full +> the self-keyed class for `tenants`, the tenant-wide class for seven, the full > org-scoped template with its `app.scope` read hatch and two `AS RESTRICTIVE` > write guards for `tenant_settings`, and the role-qualified platform-scoped class > for `platform_host_to_tenant`, which is read *before* a tenant is known. @@ -2236,8 +2240,9 @@ in a record rather than in production. > > ### What the corpus owed, and now carries > -> Four amendments, all correcting a document against a measurement rather than -> changing a decision: [ADR-0039](../decisions/0039-optimistic-concurrency-token.md) +> Eleven amendments across seven ADRs, every one correcting a document against a +> measurement rather than changing a decision. The five that change what an +> implementer does: [ADR-0039](../decisions/0039-optimistic-concurrency-token.md) > Amendment 1 (the two forbidden EF calls) and Amendment 2 (the chain is three > calls, not one, because `HasDefaultValue` sets `ValueGenerated`); > [ADR-0037](../decisions/0037-idempotency-key-contract.md) Amendment 3 @@ -2254,18 +2259,23 @@ in a record rather than in production. > [docs/modules/tenancy/](../modules/tenancy/README.md) is the repository's first > module spec, carrying all ten Standards 13 sections. > -> Nine architecture rules moved from **Registered** to **Implemented**, and the -> catalogue rows say so with the file that carries them: -> `Aggregates_With_Optimistic_Concurrency_Map_RowVersion`, +> Ten catalogue rows say **Implemented** that did not before, and each names the +> file that carries it. Only **three** were standing debt this packet closed — > `Organization_Aggregate_Declared_In_Tenancy_Domain`, -> `TenantWide_Row_Of_TenantB_Is_Invisible_To_TenantA`, -> `Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck`, +> `TenantWide_Row_Of_TenantB_Is_Invisible_To_TenantA` and +> `Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck` are the only three of the +> ten that existed in the catalogue before this packet's first commit. The other +> seven it both registered and implemented, because they descend from ADR-0039 and +> ADR-0040, which that same commit wrote: +> `Aggregates_With_Optimistic_Concurrency_Map_RowVersion`, +> `SoftDelete_Advances_The_Row_Version`, > `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork`, -> `TransactionBehavior_Does_Not_Reference_A_Module_Assembly`, and three the packet -> registered as it went — `Migration_Startup_Project_References_EntityFrameworkCore_Design`, +> `TransactionBehavior_Does_Not_Reference_A_Module_Assembly`, +> `Migration_Startup_Project_References_EntityFrameworkCore_Design`, > `Migrate_Target_Covers_Every_Migration_Chain` and > `Every_Foreign_Key_Has_A_Supporting_Index`, the last of which found two real gaps -> on its first run. +> on its first run. The split matters: the first number is how much standing corpus +> debt was outstanding, the second how much this packet created and then paid. > > ### What Packet 6 deliberately did not do > diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 7b474ec9..2580eee7 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -350,7 +350,7 @@ hole the composite key exists to close. ### Table classes Not every table in the tenancy schema is tenant-owned, and applying the template above -to all of them produces a deadlock rather than isolation. Three classes exist, and every +to all of them produces a deadlock rather than isolation. Four classes exist, and every migration states which one its table is. | Class | Rule | Tables | @@ -1108,8 +1108,10 @@ Forbidden: string interpolation with non-constant values. enforces this in the deployment config; deviation requires an ADR. - `app.tenant_id` (and `app.organization_id` when relevant) set **within the same transaction** as the work (`SET LOCAL ...`). -- A `DbCommandInterceptor` — **not** a connection-checkout interceptor — guards the - context. Checkout happens before `TransactionBehavior` opens the transaction that +- A `DbCommandInterceptor` — **not** a connection-checkout interceptor — is to guard + the context. **It does not exist yet**; Packet 6 ships the setter and the policies + it backs up, and the first tenant-owned read on a request path is Packet 7's, which + is where it belongs. Checkout happens before `TransactionBehavior` opens the transaction that carries the `SET LOCAL` values, so a checkout hook would read an unset `app.tenant_id` on every request and throw universally; under PgBouncer transaction pooling it would sometimes read a *previous* transaction's leftover value, which is diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index b3cdf576..297df1c7 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -58,15 +58,18 @@ belongs to is decided by what it needs, not by what it is about: the error shape, idempotency, limits, the tenancy edge. These run in the required `backend` CI job alongside the unit suite. - **Data tests** — real **Postgres** via Testcontainers, connected as - `learnstack_app`, one database per test class (or Respawn between tests). Not + `learnstack_app`, one database per test collection — cases that write either roll + back or clean up after themselves, because the fixture's seeded row counts are + what the isolation assertions compare against. Not Valkey and not SeaweedFS: nothing the backend runs calls either, and both sit behind the gated compose profile ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md)). Everything that is a property of the schema lives here, and **every tenant-isolation invariant** - does. The fixture and the first of these — the four-role provisioning suite — - land in **Packet 6** with the roles themselves; the tenant-isolation suite - follows in **Packet 7** with the schema's policies. Both connect as - `learnstack_app`, because a test run as the owner or as a `BYPASSRLS` role - passes against inert policies. They run in the separate `backend-integration` + does. **Packet 6** shipped the fixtures, the four-role provisioning suite, both + migration chains, the policies, a two-tenant seed and the schema-level isolation + suite; **Packet 7** re-runs those cases through `TenantResolverMiddleware` and + the EF query filters, which is the layer a handler actually meets. All of them + connect as `learnstack_app`, because a test run as the owner or as a + `BYPASSRLS` role passes against inert policies. They run in the separate `backend-integration` job, split from the Docker-free tests by `[Trait("Requires","Docker")]`. Both: real module configuration, no mocked repositories, and coverage of the diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index 8ea05fb7..966379f8 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -281,13 +281,16 @@ Because every `current_setting` read is called with its missing-OK argument (`tr **and** wrapped in `NULLIF(…, '')`, both an unset and a reset variable yield `NULL` and the policy predicate filters the row out. The failure mode is an empty result set, not a leak — but an empty result set arriving from production is an outage, so a -`DbCommandInterceptor` additionally asserts that **a sanctioned setter** has already +`DbCommandInterceptor` is to assert that **a sanctioned setter** has already issued the `SET LOCAL` pair on this transaction before any command against a `[TenantOwned]` table runs — `TransactionBehavior` in the general case, or any of the out-of-band setters above, each of which stamps the same marker on the transaction it opens. Naming `TransactionBehavior` alone would make the guard reject every write the idempotency store and the audit store legitimately make. It throws -`TenantContextMissingException` when it has not. It cannot be a connection-checkout +`TenantContextMissingException` when it has not. **It does not exist yet**: Packet 6 +ships the setter (`IUnitOfWork.SetTenantContextAsync`) and the policies it would +back up, and no packet yet owns the interceptor — the first tenant-owned read on a +request path is Packet 7's, and that is where it belongs. It cannot be a connection-checkout interceptor, for the same reason it cannot be a `DbConnectionInterceptor` that *sets* the values: checkout precedes the transaction, so the transaction-local value is not there to be observed ([05-database.md § Connection Management](05-database.md)). diff --git a/docs/standards/18-audit-coverage.md b/docs/standards/18-audit-coverage.md index 06f4b3e3..7d03225b 100644 --- a/docs/standards/18-audit-coverage.md +++ b/docs/standards/18-audit-coverage.md @@ -51,7 +51,7 @@ which carries the MUST/SHOULD/MAY audit-coverage tier. Both fields live on ## Classification Matrix Template -Every module ships this table in its module spec under `docs/modules//audit.md` (or equivalent). The matrix is part of the module's PR; reviewers refuse merges without it. The `docs/modules/` directory is created with the first module spec and does not exist yet during pre-implementation. +Every module ships this table in its module spec under `docs/modules//audit.md` (or equivalent). The matrix is part of the module's PR; reviewers refuse merges without it. The `docs/modules/` directory was created with the first module spec — [Tenancy](../modules/tenancy/README.md), in Phase 02a Packet 6. | Resource | create | update | delete | read-sensitive | security-event | |----------|:------:|:------:|:------:|:--------------:|:--------------:| diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 69d79c3e..5526f886 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -71,6 +71,7 @@ Every row below carries a status: |---|---| | **Implemented** | The test exists, runs in CI, and can fail. The row names the file. | | **Registered** | The name is reserved and the assertion is agreed; no code yet. The row names the owning phase or packet. A registered test is a commitment, not a claim. | +| **Awaiting backfill** | Decided and reserved like *Registered*, but blocked on something that does not exist yet rather than on someone writing it — usually the first code that could violate it. The row names what it waits for. | | **Retired** | Moved to § Retired with the reason and the replacement. | Each row also carries a **Kind**: @@ -92,10 +93,13 @@ not implemented is the failure mode this column exists to prevent. ### Implemented today -Twenty-three test methods exist in +Twenty-nine test methods exist in [`backend/tests/LearnStack.Tests.Architecture`](../../backend/tests/LearnStack.Tests.Architecture), shipped by [Phase 01](../roadmap/phase-01-repository-tooling.md), -[Phase 02a Packets 2–3](../roadmap/phase-02a-kernel-tenancy.md) and Packet 4. +[Phase 02a Packets 2–3](../roadmap/phase-02a-kernel-tenancy.md), Packet 4 and +Packet 6 — 43 cases once the theories expand. Methods are not rows: a `[Theory]` +is one row and many cases, and several rows pair a rule with the companion +assertion that stops it passing vacuously. **Not every implemented rule lives in that assembly.** Packet 4 added eight rules there — four API-convention ones @@ -141,16 +145,28 @@ against a host serving unversioned endpoints. | `Tenant_Headers_Are_Never_A_Resolution_Source` | `TenancyConventionTests.cs` | | `Assertion_Recorder_Is_The_Only_Mismatch_Writer` | `TenancyConventionTests.cs` | | `Assertion_Budget_Does_Not_Depend_On_ICacheService` | `TenancyConventionTests.cs` | +| `Organization_Aggregate_Declared_In_Tenancy_Domain` (per-type theory) | `TenancyConventionTests.cs` | +| `Aggregates_With_Optimistic_Concurrency_Map_RowVersion` | `PersistenceConventionTests.cs` | +| `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork` | `PersistenceConventionTests.cs` | +| `TransactionBehavior_Does_Not_Reference_A_Module_Assembly` | `PersistenceConventionTests.cs` | +| `Migration_Startup_Project_References_EntityFrameworkCore_Design` | `PersistenceConventionTests.cs` | +| `Migrate_Target_Covers_Every_Migration_Chain` | `PersistenceConventionTests.cs` | | `No_Source_Folder_Named_Verticals` | `RepositoryLayoutTests.cs` | | `Frontend_Has_Only_The_Web_App` | `RepositoryLayoutTests.cs` | -Three further rules in this catalogue are **implemented outside** that assembly and are -no less binding: +Seven further rules in this catalogue are **implemented outside** that assembly and are +no less binding. Four of them could not live in it: a policy that is well-formed +and wrong, or a foreign key with no index, is only visible against an applied +schema. | Rule | Where | |---|---| | `ValidationBehavior_DoesNotThrow_ValidationException` | `LearnStack.Tests.Unit` + `LearnStack.Tests.Integration` | | `TenantContextSpanProcessor_DoesNotThrow_When_Context_Missing` | `LearnStack.Tests.Unit` | +| `SoftDelete_Advances_The_Row_Version` | `LearnStack.Tests.Unit` (`AuditableEntityTests`) | +| `TenantWide_Row_Of_TenantB_Is_Invisible_To_TenantA` | `LearnStack.Tests.Integration` (`TenancySchemaTests`) | +| `Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck` | `LearnStack.Tests.Integration` (`TenancySchemaTests`) | +| `Every_Foreign_Key_Has_A_Supporting_Index` | `LearnStack.Tests.Integration` (`TenancySchemaTests`) | | `LearnStackException-DomainExceptionThrow` (`LS0001`) | `backend/analyzers/LearnStack.Analyzers` + `DomainExceptionThrowAnalyzerTests` | `Meta_NetArchTest_DetectsAPlantedViolation` deserves its own note: it plants a forbidden @@ -158,7 +174,10 @@ dependency and asserts NetArchTest **finds** it. If that meta-test ever passes i inverted sense — NetArchTest reporting the planted dependency as absent — every other NetArchTest-based row in this catalogue is vacuously green. Keep it in perpetuity. -Everything else in this document is **Registered**. +Every other rule in this document carries its own **Status** line, and that line — +not this section — is the authority. This index is a reader's orientation and goes +stale the moment a packet closes a row without updating it; the Status column is +what a reviewer checks. ## Canonical names and superseded spellings diff --git a/docs/standards/README.md b/docs/standards/README.md index 5c7e905d..e999e356 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -67,8 +67,9 @@ bookkeeping pass. ### Honest status today -The table below is the current, accurate state as of 2026-08-20, at HEAD with -[Phase 02a](../roadmap/phase-02a-kernel-tenancy.md) Packets 0–3, 3b and 4 shipped. +The table below is the current, accurate state as of 2026-08-28, at HEAD with +[Phase 02a](../roadmap/phase-02a-kernel-tenancy.md) Packets 0–3, 3b, 4, 5 and 6 +shipped. **The individual documents still declare `Active` in their own headers.** Reconciling the twenty-two status lines with this table is a @@ -80,16 +81,16 @@ enforced" change in one commit. Until that lands, **this table wins**. |---|---|---|---| | 00 | [Principles](00-principles.md) | **Active** | Governs every PR and every ADR; principles 1, 16 and 17 are already deciding live scope questions. | | 01 | [Architecture Standards](01-architecture-standards.md) | **Active** | Module layout shipped; `ModuleDomain_DoesNotDependOn_*` and the planted-violation meta-test are green. | -| 02 | [Backend Coding](02-backend-coding.md) | **Active** | MediatR pipeline, `Result`, `IClock`, the `LS0001` analyzer and the pipeline-order test all ship. Its EF Core and domain-modelling clauses are `Adopted` until Packet 6 brings a `DbContext`. | +| 02 | [Backend Coding](02-backend-coding.md) | **Active** | MediatR pipeline, `Result`, `IClock`, the `LS0001` analyzer and the pipeline-order test all ship. Packet 6 brought the first `DbContext`, the first aggregates and the ambient unit of work, so its EF Core and domain-modelling clauses are live too — for one module. | | 03 | [Frontend Coding](03-frontend-coding.md) | **Adopted** | ESLint and TypeScript strict mode are configured, and Packet 3b stood up the Vitest harness (jsdom + Testing Library) with one render test — so the required `frontend` check now asserts something. `apps/web` is otherwise still a scaffold with no components. First real code: [Phase 02d](../roadmap/phase-02d-walking-skeleton.md). | | 04 | [API Design](04-api-design.md) | **Active** | Packet 4 shipped the versioned route convention and its startup guards, one Problem Details shape on every error including the framework-minted ones, cursor pagination, the sort grammar, idempotency keys, ETag concurrency, correlation ids, the request-body limit and the tenancy edge — each with tests in the required `backend` check. No *business* endpoint exists yet; the conventions they will land into do. | -| 05 | [Database](05-database.md) | **Adopted** | No `DbContext` and no migration exist. The canonical RLS template it now owns is applied by Packet 6's first migration. | +| 05 | [Database](05-database.md) | **Active** | Packet 6 applied it: two migration chains, ten tables, the four-role model, and the canonical RLS template this document owns — `ENABLE` **and** `FORCE`, one `AND`-ed policy per table, an explicit `WITH CHECK` — asserted against a real PostgreSQL as `learnstack_app`. Its § Concurrency, § Table classes, § Indexes and § GRANT matrix each have a test that fails without them. Partitioning and the retention job are still ahead. | | 06 | [Testing](06-testing.md) | **Active** | Unit, architecture, contract **and** integration suites all run in the required `backend` job — Packet 4 removed the filter that used to exclude the integration assembly, which by then held the only tests that could catch an unversioned route. The Docker-bound `backend-integration` job activated in Packet 6 with the four-role provisioning suite; the split is by `[Trait("Requires","Docker")]` and the two jobs' filters are exact complements. | | 07 | [Frontend Architecture](07-frontend-architecture.md) | **Adopted** | Route groups exist as empty layouts; server/client split, tenant context and SDK shape are exercised first in [Phase 02d](../roadmap/phase-02d-walking-skeleton.md). | -| 08 | [Localization](08-localization.md) | **Adopted** | `tenant_locales` and the slug schema land in Packet 6; the i18n runtime in [Phase 04](../roadmap/phase-04-cms-media-pages.md). | +| 08 | [Localization](08-localization.md) | **Adopted** | Packet 6 shipped `tenant_locales` and the slug schema; the i18n runtime lands in [Phase 04](../roadmap/phase-04-cms-media-pages.md). Nothing enforces "exactly one default locale per tenant" yet — recorded as an open question in the [Tenancy module spec](../modules/tenancy/README.md), Packet 7's call. | | 09 | [Error Handling](09-error-handling.md) | **Active** | L1 `IExceptionHandler`, the exception hierarchy, `ProblemDetailsFactory` and `HttpStatusMap` shipped in Packet 3. | | 10 | [Observability](10-observability.md) | **Active** | Serilog → OTLP, OpenTelemetry SDK, `TenantContextSpanProcessor` and the redaction enrichers shipped in Packet 3. | -| 11 | [Security](11-security.md) | **Adopted** | No auth and no RLS yet. Packet 4 shipped the header-facing half — the tenancy edge, the trusted-hop predicate, host normalization and the anonymous rate limiter. Tenant isolation lands in Packet 7, authentication in [Phase 02b](../roadmap/phase-02b-events-auth.md). Its § Tenant Context is nonetheless the binding authority the implementing PR must follow. | +| 11 | [Security](11-security.md) | **Adopted** | No auth yet; RLS is live. Packet 6 shipped the policies, the four roles and the isolation suite that runs as `learnstack_app`, and Packet 4 shipped the header-facing half — the tenancy edge, the trusted-hop predicate, host normalization and the anonymous rate limiter. Tenant isolation lands in Packet 7, authentication in [Phase 02b](../roadmap/phase-02b-events-auth.md). Its § Tenant Context is nonetheless the binding authority the implementing PR must follow. | | 12 | [Infrastructure](12-infrastructure.md) | **Active** | Compose stack, `Makefile`, CI workflow, pre-commit hooks and secret scanning all live since Phase 01. | | 13 | [Documentation](13-documentation.md) | **Active** | Governs this corpus; the CI link audit walks changed Markdown. | | 14 | [Git Workflow](14-git-workflow.md) | **Active** | Conventional Commits, hooks and required checks are live. Two branch-protection settings — `Require approvals` and `Do not allow bypassing` — are **deferred by maintainer decision (2026-08-10)** while the repository has one active contributor; the trigger and what activating them involves are recorded in [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md). Everything else in Standards 14 is enforced today. | @@ -99,9 +100,9 @@ enforced" change in one commit. Until that lands, **this table wins**. | 18 | [Audit Coverage](18-audit-coverage.md) | **Adopted** | `AuditLogBehavior` is a shell and `audit_log` does not exist. Lands in Packet 9 under [ADR-0033](../decisions/0033-audit-durability-model.md). | | 19 | [Permissions](19-permissions.md) | **Adopted** | No permission key, policy or role exists. Lands in [Phase 03](../roadmap/phase-03-identity-admin.md). | | 20 | [Infrastructure Stack](20-infrastructure-stack.md) | **Adopted** | `ISecretProvider` shipped in Packet 3 and `DeploymentMode` branching is real, but the ports land in Packet 5 and the Dapr / Kafka / APISIX / Vault adapters are demand-gated to [Phase 11](../roadmap/phase-11-production-hardening.md) per [ADR-0035](../decisions/0035-demand-gated-infrastructure.md). | -| 21 | [Architecture Tests Catalogue](21-architecture-tests-catalogue.md) | **Active** | Twenty-two tests run in the architecture assembly and more behavioural rules run beside it; the catalogue's own per-row status column distinguishes those from the registered-but-unimplemented majority. | +| 21 | [Architecture Tests Catalogue](21-architecture-tests-catalogue.md) | **Active** | Twenty-nine test methods run in the architecture assembly and seven further implemented rules run beside it, four of them against an applied schema; the catalogue's own per-row status column distinguishes those from the registered-but-unimplemented majority. | -Twelve `Active`, ten `Adopted`. That split is the honest picture of a platform whose +Thirteen `Active`, nine `Adopted`. That split is the honest picture of a platform whose foundation is real and whose domain has not been written yet — and it is far more useful to a reviewer than twenty-two identical labels. diff --git a/scripts/seed.sh b/scripts/seed.sh index 9f0ae519..7f20ba09 100755 --- a/scripts/seed.sh +++ b/scripts/seed.sh @@ -187,27 +187,34 @@ wait_for_realm() { wait_for_realm "$KEYCLOAK_REALM_TENANT" || exit 1 wait_for_realm "$KEYCLOAK_REALM_HUB" || exit 1 -# ─── Step 3: Phase 02a deferral notice ─────────────────────────────────── -cyan "▶ Step 3/3: application-level tenant seeding (deferred to Phase 02a)" +# ─── Step 3: Packet 7 deferral notice ──────────────────────────────────── +cyan "▶ Step 3/3: application-level tenant seeding (deferred to Phase 02a Packet 7)" cat <<'NOTICE' - The platform-level Tenant aggregate + Tenancy module DbContext do not - exist yet (they ship in Phase 02a per docs/roadmap/phase-02a-kernel-tenancy.md). - Phase 01 seeding therefore stops at: + Packet 6 shipped the schema: the four database roles, both migration chains + and the Tenant + Organization aggregates. `make migrate` applies them, and + this script does not — it verifies the stack and prints credentials. + + What is still missing is a SEEDER: nothing writes the two demo tenants, and + the aggregates alone cannot be reached from a shell. Packet 7 ships the two + seed tenants (docs/roadmap/phase-02a-kernel-tenancy.md), and Phase 02d + renders both of them in a browser. + + Phase 01 seeding therefore still stops at: - Keycloak realms imported (done at compose boot, verified above) - Demo users present in each realm (seeded by the realm JSON files) - Phase 02a swaps this section for: + Packet 7 swaps this section for: dotnet run --project backend/src/LearnStack.Tools.Seeder -- \ --tenants demo-platform,demo-vertical \ --platform-admin demo-admin@learnstack.test \ --connection-string "$ConnectionStrings__Default" - The console project does not exist yet; reserve the path now so the - Phase 02a packet can drop the executable + edit this stub in one PR. + The console project does not exist yet; the path is reserved so that packet + can drop the executable and edit this stub in one PR. NOTICE From 1ed8d83cf91623a364663b618a1b83c19d25ca58 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 18:56:20 +0300 Subject: [PATCH 23/35] fix(review): close the PR review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most of it is code, and three findings are worth naming. **The registration marker vouched for contexts it did not register.** `AddModuleDbContext` recorded the type before calling `TryAddScoped`, which is a no-op when something already registered it — so an `AddDbContext` that got there first, still holding its own connection, would appear in `RegisteredContexts` and `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork` would vouch for it. That is the one case the rule exists to catch. Both or neither now. **A direct rollback followed by `CompleteAsync` reported success.** With the frame already resolved, the handle took the no-op path and returned. The unit is marked rollback-only in that state, so completing now says so — nothing it wrote was committed, and silently answering success for that is the one outcome worse than throwing. **Two natural keys were held forever by soft-deleted rows.** `ux_organizations_tenant_id_slug` and the `tenant_settings` key index were unfiltered, so a soft-deleted organization kept its slug against its own tenant. Both are partial on `deleted_at IS NULL` now, which is what `ux_tenant_domains_host` already did for the same reason. `ux_organizations_tenant_id_id` stays unfiltered — a composite foreign key needs a total unique index to reference. Also in the seam: `TransactionBehavior` takes its scope with `await using` and issues `SetTenantContextAsync` inside the try, so a failure to set the session variables fails its own frame instead of leaving it for scope disposal. Two domain guards, both of the kind this packet has been adding all along — fail where the mistake is, not three layers down. A `jsonb` column rejects malformed JSON with `22P02` naming neither the property nor the aggregate, so `TenantSetting` and `TenantFeatureFlag` parse it at the factory. And `Organization` asserts the two lengths its own configuration maps, which the database otherwise reports as `22001` with no property name. The skills were teaching things that do not work. `add-backend-module` put the `DbContext` registration in `Application`, which may not reference `Infrastructure`, under a helper name that does not exist; it now points at the composition root and `AddModuleDbContext`. `add-integration-test`'s examples were written against a `TestFixture` with `AsTenant` / `Db` members that never existed — Steps 1 and 2 are rewritten against `SchemaFixture`, `SchemaQueries` and the shipped isolation cases, and Steps 3 to 5 are marked as the shape their owning phase will provide rather than pretending they run today. `add-ef-migration` had an unclosed fence swallowing its own command. `02-create-roles.sql` now converges rather than only creating: re-running it after a password change had no effect, which matters because "apply this file by hand" is the documented recovery path. It re-asserts the bypass attribute too — that is the security-critical half, and re-running the file is the cheapest way to put a role back. The rest is corpus accuracy: a cross-reference to the wrong amendment number, a concurrency summary falsified by Amendment 2, an applied-schema rule count off by one, a stale scanner URL, a packet range, a BCP-47 claim the standard does not make, a missing CHECK in a SQL fence, and text fallbacks for the five module-spec diagrams that had none. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-backend-module/SKILL.md | 30 ++- .claude/skills/add-ef-migration/SKILL.md | 1 - .claude/skills/add-integration-test/SKILL.md | 133 ++++++++------ .claude/skills/add-mediatr-handler/SKILL.md | 7 +- .claude/skills/local-dev-setup/SKILL.md | 8 +- .claude/skills/run-tests-locally/SKILL.md | 18 +- .github/CONTRIBUTING.md | 2 +- .github/workflows/ci.yml | 2 +- CLAUDE.md | 2 +- .../Pipeline/TransactionBehavior.cs | 18 +- .../ModuleDbContextRegistration.cs | 11 ++ .../Persistence/NpgsqlUnitOfWork.cs | 11 ++ .../CompositeKeyedEntities.cs | 35 +++- .../Organization.cs | 39 ++++ .../TenantSetting.cs | 4 +- .../Persistence/Configurations.cs | 5 + ...28092437_create_tenancy_schema.Designer.cs | 6 +- .../20260828092437_create_tenancy_schema.cs | 6 +- .../TenancyDbContextModelSnapshot.cs | 6 +- .../SourceText.cs | 172 ++++++++++++++++++ .../TenancyConventionTests.cs | 165 +---------------- .../Database/UnitOfWorkTests.cs | 13 +- .../Modules/Tenancy/TenancyAggregateTests.cs | 73 ++++++++ docs/architecture/12-localization.md | 8 +- docs/architecture/21-feature-flags.md | 4 +- docs/architecture/27-custom-domain-tls.md | 6 + ...0023-strongly-typed-id-source-generator.md | 2 +- .../0031-postgresql-major-version.md | 2 +- .../0039-optimistic-concurrency-token.md | 1 + docs/decisions/0040-ambient-unit-of-work.md | 2 + docs/decisions/README.md | 2 +- docs/glossary.md | 2 +- docs/modules/tenancy/README.md | 43 ++++- docs/standards/05-database.md | 7 +- docs/standards/README.md | 2 +- .../compose/postgres-init/02-create-roles.sql | 17 ++ 36 files changed, 594 insertions(+), 271 deletions(-) create mode 100644 backend/tests/LearnStack.Tests.Architecture/SourceText.cs diff --git a/.claude/skills/add-backend-module/SKILL.md b/.claude/skills/add-backend-module/SKILL.md index 60be3b41..2004268b 100644 --- a/.claude/skills/add-backend-module/SKILL.md +++ b/.claude/skills/add-backend-module/SKILL.md @@ -102,15 +102,8 @@ public sealed class Module : ILearnStackModule { public void Register(IServiceCollection services, IConfiguration configuration) { - // NOT AddDbContext(... UseNpgsql(connectionString) ...). A context that - // opens its own connection never saw the SET LOCAL that TransactionBehavior - // issues on the ambient one, so every read through it returns ZERO ROWS - // under the corrected RLS policy — silently. Per ADR-0040 every module - // context is built on the connection IUnitOfWork owns, through the shared - // helper, and Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork fails the - // build if you reach for the EF default instead. - services.AddLearnStackDbContext<DbContext>(); - + // The DbContext is NOT registered here — see below. Application may not + // reference Infrastructure, and the registration helper lives there. services.AddMediatRFromModule(typeof(Module).Assembly); services.AddValidatorsFromAssembly(typeof(Module).Assembly); @@ -136,6 +129,25 @@ Call this from the composition root (`LearnStack.Api/Program.cs`): modules.Add(new Module()); ``` +**The `DbContext` registration is a composition-root concern, not a module one.** +`AddModuleDbContext` lives in `LearnStack.Infrastructure.Persistence`, and +`Application` may not reference `Infrastructure` — so the call belongs beside the +others in `AddLearnStackPersistence` +(`LearnStack.Api/Composition/PersistenceCompositionExtensions.cs`): + +```csharp +services.AddModuleDbContext<DbContext>(); +``` + +Not `AddDbContext(o => o.UseNpgsql(connectionString))`. A context that opens its +own connection never saw the `SET LOCAL` the ambient transaction carries, so every +read through it returns **zero rows** under the corrected RLS policy — silently. +Per [ADR-0040](../../../docs/decisions/0040-ambient-unit-of-work.md) every module +context is built on the connection `IUnitOfWork` owns, and +`Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork` fails the build if you reach +for the EF default instead — from both sides: the registration, and the fact that +only three files under `backend/src` may mention `UseNpgsql` at all. + ### Step 4: Module DbContext In `LearnStack.Modules..Infrastructure/Persistence/DbContext.cs`: diff --git a/.claude/skills/add-ef-migration/SKILL.md b/.claude/skills/add-ef-migration/SKILL.md index 4fd311c1..8b8c5c84 100644 --- a/.claude/skills/add-ef-migration/SKILL.md +++ b/.claude/skills/add-ef-migration/SKILL.md @@ -60,7 +60,6 @@ and survive forward-only deploy rules export ConnectionStrings__Migration=$(sed -n "s/^ConnectionStrings__Migration=//p" .env \ | tail -1 | tr -d "\r" | sed "s/^['\"]//; s/['\"]$//") -```bash # Pass the INTENT only, in snake_case. EF prepends the UTC timestamp itself, so # the file lands as _.cs — the format Standards 05 # specifies. Typing the timestamp as well produces it twice. diff --git a/.claude/skills/add-integration-test/SKILL.md b/.claude/skills/add-integration-test/SKILL.md index 5e614df6..7b7366ff 100644 --- a/.claude/skills/add-integration-test/SKILL.md +++ b/.claude/skills/add-integration-test/SKILL.md @@ -92,26 +92,36 @@ first statement, which is what the shipped suite does and what `IUnitOfWork.SetTenantContextAsync` does in production. ```csharp -public sealed class EnrollmentCreateTests : IClassFixture +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class EnrollmentIsolationTests { - private readonly TestFixture _fx; - public EnrollmentCreateTests(TestFixture fx) => _fx = fx; + private readonly SchemaFixture _schema; + + public EnrollmentIsolationTests(SchemaFixture schema) => _schema = schema; [Fact] - public async Task Create_succeeds_for_member_of_target_tenant() + public async Task A_tenant_sees_only_its_own_enrollments() { - var courseVersionId = await _fx.SeedCourseVersionAsync(_fx.TenantA); - - using (_fx.AsTenant(_fx.TenantA, _fx.OrgA1)) - { - var result = await _fx.Mediator.Send(new CreateEnrollmentCommand( - LearnerId: _fx.LearnerInTenantA, - CourseVersionId: courseVersionId, - CohortId: null, - Source: EnrollmentSource.Manual)); - - Assert.True(result.IsSuccess); - } + // learnstack_app, never the owner: a test that connects as the owner — or + // as either BYPASSRLS role — passes against inert policies. + await using var connection = await PostgresFixture.OpenAsync( + _schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + + // First statement inside the transaction, which is what + // IUnitOfWork.SetTenantContextAsync does in production. Outside a + // transaction the setting is discarded and every later assertion is + // measuring nothing. + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + + await using var read = new NpgsqlCommand( + "SELECT count(*) FROM enrollments", (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + + (await read.ExecuteScalarAsync()).Should().Be(1L); + + // No commit: the transaction rolls back on dispose, so the fixture's + // seeded row counts stay what the other cases assert. } } ``` @@ -120,62 +130,83 @@ public sealed class EnrollmentCreateTests : IClassFixture Every `[TenantOwned]` entity ships with these two tests **at minimum**: +The fixture seeds a row for **both** tenants — that is what makes the assertion +mean anything. A count of zero against a table nothing populated passes whatever +the policy says; that shipped once, in Packet 6. + ```csharp [Fact] -public async Task Entity_TenantA_cannot_read_TenantB_data() +public async Task Tenant_A_cannot_read_Tenant_B_data() { - using (_fx.AsTenant(_fx.TenantA)) { - await _fx.CreateEntityAsync(); // commits a row tagged tenant_a - } + await using var connection = await PostgresFixture.OpenAsync( + _schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); - using (_fx.AsTenant(_fx.TenantB)) { - var rows = await _fx.Db.Entities.ToListAsync(); - Assert.Empty(rows); // RLS + filter both enforce - } + await using var read = new NpgsqlCommand( + "SELECT count(*) FROM entities WHERE tenant_id = @other", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + read.Parameters.AddWithValue("other", SchemaFixture.TenantB); + + (await read.ExecuteScalarAsync()).Should().Be(0L); } [Fact] -public async Task Entity_query_with_no_tenant_context_returns_zero_rows() +public async Task Unsetting_tenant_context_returns_zero_rows_through_RLS() { - using (_fx.AsTenant(_fx.TenantA)) { - await _fx.CreateEntityAsync(); - } - - using (_fx.AsNoTenant()) // app.tenant_id unset - { - // Either: the interceptor throws TenantContextMissingException - // Or: RLS returns zero rows (no `app.tenant_id`). - var act = async () => await _fx.Db.Entities.ToListAsync(); - await Assert.ThrowsAsync(act); - } + // No transaction and no set_config: app.tenant_id is unset, the policy + // predicate is NULL, and NULL is false for USING and WITH CHECK alike. + await using var connection = await PostgresFixture.OpenAsync( + _schema.Postgres.AppConnectionString); + await using var read = new NpgsqlCommand( + "SELECT count(*) FROM entities", (NpgsqlConnection)connection); + + (await read.ExecuteScalarAsync()).Should().Be(0L); } ``` +There is no `TenantContextMissingException` today: the `DbCommandInterceptor` that +would throw it is described in Standards 05 and 11 and owned by Packet 7. Until it +exists, the fail-closed behaviour is the empty result, which is what to assert. + For `[OrganizationScoped]` entities, add the cross-org pair: ```csharp [Fact] -public async Task OrgScopedEntity_OrgX_cannot_read_OrgY_within_TenantA() +public async Task Org_X_cannot_read_Org_Y_within_TenantA() { - using (_fx.AsTenant(_fx.TenantA, _fx.OrgA1)) { - await _fx.CreateEntityAsync(); - } - - using (_fx.AsTenant(_fx.TenantA, _fx.OrgA2)) { - var rows = await _fx.Db.Entities.ToListAsync(); - Assert.Empty(rows); - } - - // Tenant-wide membership (no org) still sees the row by design: - using (_fx.AsTenant(_fx.TenantA, organizationId: null)) { - var rows = await _fx.Db.Entities.ToListAsync(); - Assert.NotEmpty(rows); - } + await using var connection = await PostgresFixture.OpenAsync( + _schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await SchemaQueries.SetSettingAsync( + connection, transaction, "app.organization_id", SchemaFixture.OrgA1.ToString()); + + await using var read = new NpgsqlCommand( + "SELECT count(*) FROM entities WHERE organization_id = @other", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + read.Parameters.AddWithValue("other", SchemaFixture.OrgA2); + + (await read.ExecuteScalarAsync()).Should().Be(0L); } ``` +And one more, which the org-scoped template needs and an ordinary session cannot +reach: with `app.scope = 'tenant'` set, the **read** widens across organizations +and neither write does. Without that case both `AS RESTRICTIVE` guards can be +deleted with the suite green — measured, in Packet 6. See +`TenancySchemaTests.TheTenantScopeHatchWidensReadsAndNeitherWrite`. + ### Step 3: Outbox round-trip +> **Steps 3 to 5 are the shape, not today's API.** `IOutbox`, the outbox +> dispatcher and `audit_log` do not exist yet — Phase 02b owns the first two, +> Packet 9 the third — and the durable `IIdempotencyStore` ships on its ADR-0035 +> trigger. The `_fx.*` members below are illustrative of what those phases will +> provide; today the only fixtures are `PostgresFixture` and `SchemaFixture`, and +> the only session-context helper is `SchemaQueries`. Write against Step 1 and +> Step 2's shapes until the owning phase lands. + For a handler that publishes an integration event: ```csharp diff --git a/.claude/skills/add-mediatr-handler/SKILL.md b/.claude/skills/add-mediatr-handler/SKILL.md index d328e1a9..770cb9cd 100644 --- a/.claude/skills/add-mediatr-handler/SKILL.md +++ b/.claude/skills/add-mediatr-handler/SKILL.md @@ -115,7 +115,12 @@ In `.Application//CommandHandler.cs`: public sealed class CreateEnrollmentCommandHandler( EnrollmentDbContext db, ITenantContext tenantContext, - IOutbox outbox) + IOutbox outbox, + // EventId and OccurredAt are `required` on IntegrationEventBase and the + // initializer below does not compile without both. They are injected rather + // than ambient so a test can pin them (02-backend-coding.md § Time). + IGuidFactory guidFactory, + IClock clock) : IRequestHandler> { // Parameter names match IRequestHandler<,>.Handle exactly. CA1725 is an error diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index 9d405b5b..e7d7a528 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -121,9 +121,13 @@ export ConnectionStrings__Default=$(sed -n "s/^ConnectionStrings__Default=//p" . # Or once, into the user-secrets store the API project already declares # (UserSecretsId learnstack-api-dev) — kept outside the repository, so it cannot -# be committed: +# be committed. Reads .env itself rather than the variable above, so it works in +# a shell that never ran the export, and refuses to store an empty value. +default_cs=$(sed -n "s/^ConnectionStrings__Default=//p" .env \ + | tail -1 | tr -d "\r" | sed "s/^['\"]//; s/['\"]$//") +[ -n "$default_cs" ] || { echo "ConnectionStrings__Default missing from .env"; exit 1; } dotnet user-secrets --project backend/src/LearnStack.Api \ - set "ConnectionStrings:Default" "$ConnectionStrings__Default" + set "ConnectionStrings:Default" "$default_cs" ``` The value names **`learnstack_app`** and the composition root refuses anything diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index 322424e5..7ede44c5 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -60,8 +60,9 @@ docker info >/dev/null && echo "docker OK" backend/tests/ LearnStack.Tests.Unit/ # No DB, no Docker. Pure unit tests. LearnStack.Tests.Architecture/ # Reflection + Roslyn + migration-scan rules. - LearnStack.Tests.Integration/ # WebApplicationFactory HTTP tests today; Testcontainers - # Postgres (as learnstack_app) from Phase 02a Packet 6. + LearnStack.Tests.Integration/ # WebApplicationFactory HTTP tests (no Docker) AND the + # Testcontainers Postgres suite under Database/ (as + # learnstack_app), split by [Trait("Requires","Docker")]. LearnStack.Tests.Contract/ # OpenAPI / SDK contract assertions. frontend/apps/web/ # Vitest + axe-core + Playwright (E2E). @@ -104,9 +105,13 @@ Common failure messages and fixes: ### Step 5: Run integration tests -Today these are `WebApplicationFactory` HTTP tests and need no Docker at all. From -Phase 02a Packet 6 a Testcontainers **Postgres** fixture joins them — no Valkey and -no Kafka, because nothing the backend runs calls either ([ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md)). +The assembly holds two kinds. The `WebApplicationFactory` HTTP tests need no +Docker; everything under `Database/` does, and **a running Docker daemon is +required** for it — Postgres only, no Valkey and no Kafka, because nothing the +backend runs calls either +([ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md)). The two +are split by `[Trait("Requires","Docker")]`, and CI runs them as two jobs; locally +`--filter "Requires!=Docker"` gives you the Docker-free half. ```bash dotnet test backend/tests/LearnStack.Tests.Integration \ @@ -122,8 +127,7 @@ Common failure shapes: | Symptom | Likely cause | |---------|--------------| | Test passes locally, fails in CI | Race condition; check `await` chains. | -| `TenantContextMissingException` | Test forgot `using fixture.AsTenant(...)`. | -| Empty result where rows should exist | Wrong tenant context — RLS works as designed. | +| Empty result where rows should exist | No `app.tenant_id` on this transaction, or the wrong one — RLS working as designed. Set it with `SchemaQueries.SetTenantAsync` as the transaction's first statement. | | `relation "
" does not exist` | Migration didn't apply; check the module's `Persistence/Migrations`. | | Docker container fails to start | Port collision (5432, 6379) — stop local Postgres / Valkey. | | Test hangs | A handler awaiting Dapr in the dev fallback path; check `IEventBus` registration. | diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 41ce93ed..9759c2fa 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -135,7 +135,7 @@ directory target. The hook detects that and skips the local scan with an upgrade hint rather than failing your commit; CI scans the whole tree either way. -The secret scanner is [Leakwatch](https://github.com/cemililik/Leakwatch) +The secret scanner is [Leakwatch](https://github.com/HodeTech/leakwatch) — MIT licensed, verifier-equipped, hybrid Aho-Corasick + regex + entropy detection engine. Config lives at `.leakwatch.yaml` + `.leakwatchignore` at the repo root. Install once for the local pre-commit scan (CI runs it diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f44bec1..3d56da7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -326,7 +326,7 @@ jobs: echo "::error::subject is not Conventional Commits: ${subject}" fail=1 fi - done < <(git log -z --format='%s' "${BASE_SHA}..${HEAD_SHA}") + done < <(git log -z --no-merges --format='%s' "${BASE_SHA}..${HEAD_SHA}") if [ "$fail" -ne 0 ]; then echo "Standards 14 § Commit messages: type(scope): subject, imperative, <= 72 chars." exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index 2c69fa72..fbecdb69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ repository holds only LearnStack's side of the boundary, in **Phase 01 complete. [Phase 02a](docs/roadmap/phase-02a-kernel-tenancy.md) in progress — -packets 0–3, 3b, 4, 5 and 6 shipped; packets 4–10 were re-scoped on 2026-08-08 +packets 0–3, 3b, 4, 5 and 6 shipped; packets 3b–10 were re-scoped on 2026-08-08 after a four-report audit of the corpus. [Packet 7](docs/roadmap/phase-02a-kernel-tenancy.md#packet-sequence) — host and tenant resolution, the EF query filters, the request-level isolation suite and diff --git a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs index d344f5c4..8e643fc8 100644 --- a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs @@ -79,19 +79,21 @@ public async Task Handle( // its own depth, so a nested frame nobody resolved is an exception here // rather than a commit that quietly resolves the wrong frame, writes // nothing, and returns success. - var scope = await unitOfWork.BeginTransactionAsync(cancellationToken); - - // First statement inside the transaction, per ADR-0003 Amendment 3. Until - // Packet 7's TenantResolverMiddleware populates ITenantContext this writes - // the empty string, and that is correct: the policies read - // NULLIF(current_setting(...), '')::uuid, so an unresolved context is a - // NULL predicate and every tenant-owned table returns zero rows. - await unitOfWork.SetTenantContextAsync(tenantContext, cancellationToken); + await using var scope = await unitOfWork.BeginTransactionAsync(cancellationToken); var committing = false; try { + // First statement inside the transaction, per ADR-0003 Amendment 3, and + // inside the try so a failure to issue it fails the frame rather than + // leaving it open for the scope to clean up later. Until Packet 7's + // TenantResolverMiddleware populates ITenantContext this writes the + // empty string, and that is correct: the policies read + // NULLIF(current_setting(...), '')::uuid, so an unresolved context is a + // NULL predicate and every tenant-owned table returns zero rows. + await unitOfWork.SetTenantContextAsync(tenantContext, cancellationToken); + var response = await next(); if (response.IsFailure) diff --git a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs index 843cf5a5..e975b1b6 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs @@ -68,6 +68,17 @@ public static IServiceCollection AddModuleDbContext(this IServiceColle { ArgumentNullException.ThrowIfNull(services); + // Both or neither. TryAddScoped is a no-op when something already + // registered TContext, and recording it anyway would make + // Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork report a context this + // helper did not build — which is precisely the case the rule exists to + // catch: an AddDbContext registration that got there first, still holding + // its own connection, with the marker set vouching for it. + if (services.Any(descriptor => descriptor.ServiceType == typeof(TContext))) + { + return services; + } + lock (Registered) { Registered.Add(typeof(TContext)); diff --git a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs index 7980f12a..028bbbdf 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs @@ -325,6 +325,17 @@ public Task CompleteAsync(CancellationToken cancellationToken = default) { if (AlreadyResolved()) { + if (unitOfWork._rollbackOnly) + { + // The frame is gone because something failed it — a direct + // RollbackAsync, or a collapse — not because it committed. + // Returning quietly here would report success for a unit that + // wrote nothing, which is the one outcome worse than throwing. + throw new InvalidOperationException( + "This frame was already resolved by a rollback; it cannot complete. " + + "The unit is marked rollback-only and nothing it wrote was committed."); + } + return Task.CompletedTask; } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs index 2ec868b7..b9d824a5 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs @@ -104,7 +104,7 @@ public static TenantFeatureFlag Create( TenantId tenantId, string key, string value, DateTimeOffset at, UserId by) { ArgumentException.ThrowIfNullOrWhiteSpace(key); - ArgumentException.ThrowIfNullOrWhiteSpace(value); + JsonValue.EnsureWellFormed(value, nameof(value)); if (!tenantId.IsInitialized()) { @@ -123,10 +123,41 @@ public static TenantFeatureFlag Create( public void SetValue(string value, DateTimeOffset at, UserId by) { - ArgumentException.ThrowIfNullOrWhiteSpace(value); + JsonValue.EnsureWellFormed(value, nameof(value)); Value = value; UpdatedAt = at; UpdatedBy = by; } } + +/// +/// Guards a value on its way into a jsonb column. +/// +/// +/// PostgreSQL rejects malformed JSON on the insert with 22P02, three +/// layers from the call that produced it and naming neither the property nor the +/// aggregate. Parsing here is one pass over a value the caller already holds, and +/// it turns that into an ArgumentException at the call site — the same +/// reason TenantDomain runs the host through EffectiveHost.Normalize +/// rather than waiting for its CHECK. +/// +internal static class JsonValue +{ + public static void EnsureWellFormed(string value, string parameterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value, parameterName); + + try + { + using var _ = System.Text.Json.JsonDocument.Parse(value); + } + catch (System.Text.Json.JsonException exception) + { + throw new ArgumentException( + $"The value is not well-formed JSON and the column is jsonb: {exception.Message}", + parameterName, + exception); + } + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs index f1605099..703d1e56 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs @@ -70,6 +70,10 @@ private Organization() /// Advisory here: what actually resolves a request is a /// platform_host_to_tenant row, which is read before any tenant context /// exists. This column records intent; the mapping table records resolution. + /// No write path yet does not take it and no + /// mutator sets it. It arrives with the host lifecycle in + /// Phase + /// 02c, which is what decides when a subdomain is claimed. /// public string? CustomSubdomain { get; private set; } @@ -83,6 +87,12 @@ private Organization() /// Not enforced as a hierarchy: no cycle check, no depth limit, and nothing /// resolves through it. ADR-0017 keeps the isolation model flat precisely so /// that a policy never has to walk a tree. + /// No write path yet, for the same reason as + /// : reporting roll-up is an admin operation and + /// arrives with the organization admin surface in + /// Phase + /// 03. The composite foreign key and its index ship now because both are + /// one-way doors; the mutator is additive. /// public OrganizationId? ReportingParentId { get; private set; } @@ -105,6 +115,7 @@ public static Organization Create( ArgumentNullException.ThrowIfNull(clock); ArgumentException.ThrowIfNullOrWhiteSpace(slug); ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + EnsureWithinMappedLengths(slug, displayName); if (!id.IsInitialized()) { @@ -137,11 +148,39 @@ public void Rename(string displayName, IClock clock, UserId updatedBy) { ArgumentNullException.ThrowIfNull(clock); ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + EnsureWithinMappedLengths(Slug, displayName); DisplayName = displayName; MarkUpdated(clock.UtcNow, updatedBy); } + /// + /// The two bounds the EF configuration maps, asserted where the value is set. + /// + /// + /// The database rejects a longer value with 22001, three layers from + /// the call that produced it and with no property name. These are the same + /// numbers OrganizationConfiguration declares — 63 for the slug, which + /// is a DNS label, and 200 for the display name — and they are asserted here + /// so the failure names what is wrong. + /// + private static void EnsureWithinMappedLengths(string slug, string displayName) + { + if (slug.Length > 63) + { + throw new ArgumentException( + $"Slug is {slug.Length} characters; the column holds 63, the DNS label limit.", + nameof(slug)); + } + + if (displayName.Length > 200) + { + throw new ArgumentException( + $"DisplayName is {displayName.Length} characters; the column holds 200.", + nameof(displayName)); + } + } + /// Moves the organization to a new lifecycle state. public void ChangeStatus(OrganizationStatus status, IClock clock, UserId updatedBy) { diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs index f00c392d..4af0872b 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs @@ -67,7 +67,7 @@ public static TenantSetting Create( { ArgumentNullException.ThrowIfNull(clock); ArgumentException.ThrowIfNullOrWhiteSpace(key); - ArgumentException.ThrowIfNullOrWhiteSpace(value); + JsonValue.EnsureWellFormed(value, nameof(value)); if (!id.IsInitialized()) { @@ -107,7 +107,7 @@ public static TenantSetting Create( public void SetValue(string value, IClock clock, UserId updatedBy) { ArgumentNullException.ThrowIfNull(clock); - ArgumentException.ThrowIfNullOrWhiteSpace(value); + JsonValue.EnsureWellFormed(value, nameof(value)); Value = value; MarkUpdated(clock.UtcNow, updatedBy); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs index 85ded34a..0c586c16 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs @@ -138,8 +138,12 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.ReportingParentId) .HasConversion(); + // Partial on `deleted_at IS NULL`, for the reason ux_tenant_domains_host is: + // a soft-deleted row that keeps its natural key holds that name against the + // tenant forever, and nothing frees it. builder.HasIndex(x => new { x.TenantId, x.Slug }) .IsUnique() + .HasFilter("deleted_at IS NULL") .HasDatabaseName("ux_organizations_tenant_id_slug"); // Exists solely so tenants.default_organization_id — and every future @@ -257,6 +261,7 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(x => new { x.TenantId, x.OrganizationId, x.Key }) .IsUnique() .AreNullsDistinct(false) + .HasFilter("deleted_at IS NULL") .HasDatabaseName("ux_tenant_settings_tenant_id_organization_id_key"); builder.HasIndex(x => new { x.TenantId, x.OrganizationId }) diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.Designer.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.Designer.cs index 8343cf8f..9a98e903 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.Designer.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.Designer.cs @@ -103,7 +103,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("TenantId", "Slug") .IsUnique() - .HasDatabaseName("ux_organizations_tenant_id_slug"); + .HasDatabaseName("ux_organizations_tenant_id_slug") + .HasFilter("deleted_at IS NULL"); b.ToTable("organizations", (string)null); }); @@ -478,7 +479,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("TenantId", "OrganizationId", "Key") .IsUnique() - .HasDatabaseName("ux_tenant_settings_tenant_id_organization_id_key"); + .HasDatabaseName("ux_tenant_settings_tenant_id_organization_id_key") + .HasFilter("deleted_at IS NULL"); NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("TenantId", "OrganizationId", "Key"), false); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.cs index 4a9ba817..77ea1265 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.cs @@ -184,7 +184,8 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "ux_organizations_tenant_id_slug", table: "organizations", columns: new[] { "tenant_id", "slug" }, - unique: true); + unique: true, + filter: "deleted_at IS NULL"); migrationBuilder.CreateIndex( name: "ix_platform_host_to_tenant_tenant_id_organization_id", @@ -212,7 +213,8 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "ux_tenant_settings_tenant_id_organization_id_key", table: "tenant_settings", columns: new[] { "tenant_id", "organization_id", "key" }, - unique: true) + unique: true, + filter: "deleted_at IS NULL") .Annotation("Npgsql:NullsDistinct", false); migrationBuilder.CreateIndex( diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs index d7d3ebda..93bb58b0 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs @@ -100,7 +100,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("TenantId", "Slug") .IsUnique() - .HasDatabaseName("ux_organizations_tenant_id_slug"); + .HasDatabaseName("ux_organizations_tenant_id_slug") + .HasFilter("deleted_at IS NULL"); b.ToTable("organizations", (string)null); }); @@ -475,7 +476,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("TenantId", "OrganizationId", "Key") .IsUnique() - .HasDatabaseName("ux_tenant_settings_tenant_id_organization_id_key"); + .HasDatabaseName("ux_tenant_settings_tenant_id_organization_id_key") + .HasFilter("deleted_at IS NULL"); NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("TenantId", "OrganizationId", "Key"), false); diff --git a/backend/tests/LearnStack.Tests.Architecture/SourceText.cs b/backend/tests/LearnStack.Tests.Architecture/SourceText.cs new file mode 100644 index 00000000..dd6a50cf --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/SourceText.cs @@ -0,0 +1,172 @@ +namespace LearnStack.Tests.Architecture; + +/// +/// Comment stripping for the source scans, shared because both scanning rules +/// need the same answer and a second implementation is a second answer. +/// +internal static class SourceText +{ + /// Strips line and block comments, leaving literals alone. + /// + /// Literal state is tracked, because a // inside a string is not a + /// comment: "https://…" would otherwise truncate the rest of that + /// line, and anything after it — including a banned literal — would go + /// unseen. A false negative in a rule that guards the tenancy edge is worth + /// the twenty lines. + /// + public static string WithoutComments(string source) + { + var kept = new System.Text.StringBuilder(source.Length); + var i = 0; + + while (i < source.Length) + { + var c = source[i]; + + if (c == '/' && i + 1 < source.Length && source[i + 1] == '/') + { + while (i < source.Length && source[i] != '\n') + { + i++; + } + + continue; + } + + if (c == '/' && i + 1 < source.Length && source[i + 1] == '*') + { + var close = source.IndexOf("*/", i + 2, StringComparison.Ordinal); + i = close < 0 ? source.Length : close + 2; + continue; + } + + // A literal is copied through verbatim, so nothing inside it is read + // as a comment marker — and nothing inside it is lost, so a banned + // literal written as a string is still found. + if (c is '"' or '\'') + { + i = CopyLiteral(source, i, kept); + continue; + } + + kept.Append(c); + i++; + } + + return kept.ToString(); + } + + /// Copies one string or character literal and returns the index after it. + /// + /// Three shapes, because C# has three and they terminate differently: a + /// normal literal ends at an unescaped quote, a verbatim one (@"…") + /// escapes a quote by doubling it, and a raw one opens with a run of + /// three or more quotes and closes only on a run of the same length. Reading + /// a raw literal's first quote as its terminator puts the scanner back + /// inside code while it is still inside a string — which is how a // + /// there would swallow the rest of the line again. + /// + public static int CopyLiteral(string source, int start, System.Text.StringBuilder kept) + { + var quote = source[start]; + + if (quote == '"') + { + var opening = 0; + while (start + opening < source.Length && source[start + opening] == '"') + { + opening++; + } + + if (opening >= 3) + { + return CopyRawLiteral(source, start, opening, kept); + } + } + + var verbatim = start > 0 && source[start - 1] == '@'; + var i = start; + + kept.Append(source[i]); + i++; + + while (i < source.Length) + { + var c = source[i]; + + if (!verbatim && c == '\\' && i + 1 < source.Length) + { + kept.Append(c).Append(source[i + 1]); + i += 2; + continue; + } + + if (c == quote) + { + if (verbatim && i + 1 < source.Length && source[i + 1] == quote) + { + kept.Append(c).Append(source[i + 1]); + i += 2; + continue; + } + + kept.Append(c); + return i + 1; + } + + // An unterminated non-verbatim literal cannot span a line; bailing + // keeps a malformed file from swallowing the rest of the scan. + if (!verbatim && c == '\n') + { + return i; + } + + kept.Append(c); + i++; + } + + return i; + } + + /// Copies a raw string literal, closing only on a run of the opening length. + private static int CopyRawLiteral( + string source, int start, int opening, System.Text.StringBuilder kept) + { + var i = start; + kept.Append(source, i, opening); + i += opening; + + while (i < source.Length) + { + if (source[i] != '"') + { + kept.Append(source[i]); + i++; + continue; + } + + var run = 0; + while (i + run < source.Length && source[i + run] == '"') + { + run++; + } + + kept.Append(source, i, run); + i += run; + + if (run >= opening) + { + return i; + } + } + + return i; + } + + /// + /// The source with every whitespace character removed, so a banned literal + /// cannot hide behind a line break. + /// + public static string WithoutWhitespace(string value) => + string.Concat(value.Where(character => !char.IsWhiteSpace(character))); +} diff --git a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs index 4cf8dbe9..cd6afab2 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs @@ -206,11 +206,12 @@ private static List Offenders( continue; } - var code = WithoutWhitespace(WithoutComments(File.ReadAllText(file))); + var code = SourceText.WithoutWhitespace( + SourceText.WithoutComments(File.ReadAllText(file))); foreach (var literal in banned) { - if (code.Contains(WithoutWhitespace(literal), StringComparison.Ordinal)) + if (code.Contains(SourceText.WithoutWhitespace(literal), StringComparison.Ordinal)) { offenders.Add($"{relative} contains '{literal}'"); } @@ -219,164 +220,4 @@ private static List Offenders( return offenders; } - - /// Strips line and block comments, leaving literals alone. - /// - /// Literal state is tracked, because a // inside a string is not a - /// comment: "https://…" would otherwise truncate the rest of that - /// line, and anything after it — including a banned literal — would go - /// unseen. A false negative in a rule that guards the tenancy edge is worth - /// the twenty lines. - /// - private static string WithoutComments(string source) - { - var kept = new System.Text.StringBuilder(source.Length); - var i = 0; - - while (i < source.Length) - { - var c = source[i]; - - if (c == '/' && i + 1 < source.Length && source[i + 1] == '/') - { - while (i < source.Length && source[i] != '\n') - { - i++; - } - - continue; - } - - if (c == '/' && i + 1 < source.Length && source[i + 1] == '*') - { - var close = source.IndexOf("*/", i + 2, StringComparison.Ordinal); - i = close < 0 ? source.Length : close + 2; - continue; - } - - // A literal is copied through verbatim, so nothing inside it is read - // as a comment marker — and nothing inside it is lost, so a banned - // literal written as a string is still found. - if (c is '"' or '\'') - { - i = CopyLiteral(source, i, kept); - continue; - } - - kept.Append(c); - i++; - } - - return kept.ToString(); - } - - /// Copies one string or character literal and returns the index after it. - /// - /// Three shapes, because C# has three and they terminate differently: a - /// normal literal ends at an unescaped quote, a verbatim one (@"…") - /// escapes a quote by doubling it, and a raw one opens with a run of - /// three or more quotes and closes only on a run of the same length. Reading - /// a raw literal's first quote as its terminator puts the scanner back - /// inside code while it is still inside a string — which is how a // - /// there would swallow the rest of the line again. - /// - private static int CopyLiteral(string source, int start, System.Text.StringBuilder kept) - { - var quote = source[start]; - - if (quote == '"') - { - var opening = 0; - while (start + opening < source.Length && source[start + opening] == '"') - { - opening++; - } - - if (opening >= 3) - { - return CopyRawLiteral(source, start, opening, kept); - } - } - - var verbatim = start > 0 && source[start - 1] == '@'; - var i = start; - - kept.Append(source[i]); - i++; - - while (i < source.Length) - { - var c = source[i]; - - if (!verbatim && c == '\\' && i + 1 < source.Length) - { - kept.Append(c).Append(source[i + 1]); - i += 2; - continue; - } - - if (c == quote) - { - if (verbatim && i + 1 < source.Length && source[i + 1] == quote) - { - kept.Append(c).Append(source[i + 1]); - i += 2; - continue; - } - - kept.Append(c); - return i + 1; - } - - // An unterminated non-verbatim literal cannot span a line; bailing - // keeps a malformed file from swallowing the rest of the scan. - if (!verbatim && c == '\n') - { - return i; - } - - kept.Append(c); - i++; - } - - return i; - } - - /// Copies a raw string literal, closing only on a run of the opening length. - private static int CopyRawLiteral( - string source, int start, int opening, System.Text.StringBuilder kept) - { - var i = start; - kept.Append(source, i, opening); - i += opening; - - while (i < source.Length) - { - if (source[i] != '"') - { - kept.Append(source[i]); - i++; - continue; - } - - var run = 0; - while (i + run < source.Length && source[i + run] == '"') - { - run++; - } - - kept.Append(source, i, run); - i += run; - - if (run >= opening) - { - return i; - } - } - - return i; - } - - private static string WithoutWhitespace(string value) => - string.Concat(value.Where(character => !char.IsWhiteSpace(character))); } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index fdb50fca..a898b246 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -305,8 +305,13 @@ public async Task Disposing_a_frame_out_of_order_collapses_the_whole_unit() unitOfWork.HasActiveTransaction.Should().BeFalse( "the outer frame ended unresolved, so the whole unit is done"); - // And the abandoned inner frame cannot revive it. - await inner.CompleteAsync(); + // And the abandoned inner frame cannot revive it — nor quietly report + // success. Its frame is gone because the collapse failed it, not because + // it committed, and the unit is marked; completing says so. + var completeAbandoned = async () => await inner.CompleteAsync(); + + (await completeAbandoned.Should().ThrowAsync()) + .WithMessage("*already resolved by a rollback*"); unitOfWork.HasActiveTransaction.Should().BeFalse(); var begin = async () => await unitOfWork.BeginTransactionAsync(); @@ -573,6 +578,10 @@ private static StubTenantContext Resolved(Guid tenant, Guid organization) => /// A request type for driving the real behavior. public sealed record Probe : MediatR.IRequest>; + /// + /// A resolved context, standing in for what Packet 7's + /// TenantResolverMiddleware will populate. + /// private sealed class StubTenantContext(Guid tenant, Guid organization) : ITenantContext { public bool IsResolved => true; diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs index 8b0dc4da..aab3779c 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs @@ -153,6 +153,79 @@ public void Every_factory_refuses_an_unassigned_tenant() domain.Should().Throw(); } + [Theory] + // The column is jsonb, so PostgreSQL rejects malformed JSON with 22P02 three + // layers from the call that produced it, naming neither the property nor the + // aggregate. Parsing at the factory turns that into an ArgumentException at + // the call site — the same reason TenantDomain runs its host through + // EffectiveHost.Normalize rather than waiting for the CHECK. + [InlineData("\"Europe/Istanbul\"", true)] + [InlineData("{\"a\":1}", true)] + [InlineData("true", true)] + [InlineData("Europe/Istanbul", false)] + [InlineData("{\"a\":}", false)] + [InlineData("{", false)] + public void A_setting_value_must_be_well_formed_json(string value, bool accepted) + { + var settingId = TenantSettingId.From(Guid.Parse("55555555-1111-7111-8111-111111111111")); + + var create = () => TenantSetting.Create( + settingId, Tenant, null, "tz", value, Clock, Actor); + + if (accepted) + { + create.Should().NotThrow(); + } + else + { + create.Should().Throw().WithMessage("*jsonb*"); + } + } + + [Theory] + [InlineData("true", true)] + [InlineData("yes", false)] + public void A_feature_flag_value_must_be_well_formed_json(string value, bool accepted) + { + var create = () => TenantFeatureFlag.Create(Tenant, "live-classroom", value, Clock.UtcNow, Actor); + + if (accepted) + { + create.Should().NotThrow(); + } + else + { + create.Should().Throw().WithMessage("*jsonb*"); + } + } + + [Theory] + // The two bounds OrganizationConfiguration maps. The database rejects a longer + // value with 22001 and no property name. + [InlineData(63, 200, true)] + [InlineData(64, 200, false)] + [InlineData(63, 201, false)] + public void An_organization_is_bounded_by_the_lengths_its_columns_hold( + int slugLength, int displayNameLength, bool accepted) + { + var create = () => Organization.Create( + OrganizationId.From(Guid.Parse("aaaaaaaa-1111-7111-8111-111111111111")), + Tenant, + new string('s', slugLength), + new string('d', displayNameLength), + Clock, + Actor); + + if (accepted) + { + create.Should().NotThrow(); + } + else + { + create.Should().Throw(); + } + } + /// /// An identifier nobody assigned, obtained the only way that compiles. /// diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index 0f98c0bf..3e33ed5d 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -31,7 +31,7 @@ Out of scope for the initial implementation: -- in tenancy module CREATE TABLE tenant_locales ( tenant_id UUID NOT NULL, - locale VARCHAR(35) NOT NULL, -- BCP-47's maximum well-formed length + locale VARCHAR(35) NOT NULL, -- an application bound, not a BCP-47 one is_default BOOLEAN NOT NULL, is_enabled BOOLEAN NOT NULL DEFAULT TRUE, sort SMALLINT NOT NULL DEFAULT 0, @@ -45,6 +45,12 @@ The shipped table is the Tenancy module's migration, which adds the audit-free composite primary key shown above plus `ENABLE`/`FORCE ROW LEVEL SECURITY` and the tenant-wide policy; this fence is the column sketch, not the DDL. +`varchar(35)` is an **application** bound, not a BCP-47 one — the tag grammar sets no +maximum, and a well-formed tag longer than 35 characters exists and would be rejected +here. The number is the practical ceiling for the language-script-region-variant shapes +a tenant publishes in; well-formedness itself is validated in application code, not by +this column. + ## Storage Schema: Translatable Fields Two patterns are used; the choice depends on the entity shape. diff --git a/docs/architecture/21-feature-flags.md b/docs/architecture/21-feature-flags.md index 539db248..24d2c6cf 100644 --- a/docs/architecture/21-feature-flags.md +++ b/docs/architecture/21-feature-flags.md @@ -133,7 +133,9 @@ CREATE TABLE platform_entitlement_cache ( generation bigint NOT NULL DEFAULT 1, -- monotonic; a push is accepted only -- when received.generation >= stored refreshed_at timestamptz NOT NULL DEFAULT now(), - source text NOT NULL -- 'hub' | 'signed-license-key' | 'null-provider' + source text NOT NULL, -- closed set, bounded by the CHECK below + CONSTRAINT ck_platform_entitlement_cache_source + CHECK (source IN ('hub', 'signed-license-key', 'null-provider')) ); ``` diff --git a/docs/architecture/27-custom-domain-tls.md b/docs/architecture/27-custom-domain-tls.md index dbec28fc..87f42f50 100644 --- a/docs/architecture/27-custom-domain-tls.md +++ b/docs/architecture/27-custom-domain-tls.md @@ -217,6 +217,12 @@ namespace LearnStack.Infrastructure.MultiTenancy; public interface IHostToTenantResolver { + // `host` is the EFFECTIVE host, already normalized by EffectiveHostAccessor — + // lowercase, punycoded, no port, no trailing dot. It is not re-normalized + // here: ADR-0036 puts that computation in exactly one place, and + // Effective_Host_Computed_In_One_Place fails a second one. A caller that + // passes a raw Host header gets a cache key and a policy predicate that do + // not match the stored row, which is a 404 rather than a wider read. Task ResolveAsync(string host, CancellationToken ct = default); } diff --git a/docs/decisions/0023-strongly-typed-id-source-generator.md b/docs/decisions/0023-strongly-typed-id-source-generator.md index 037ff837..7fe2fea9 100644 --- a/docs/decisions/0023-strongly-typed-id-source-generator.md +++ b/docs/decisions/0023-strongly-typed-id-source-generator.md @@ -236,7 +236,7 @@ Three things outweighed the appeal: - DB-side (`uuidv7()` per ADR-0031) for `audit_log`, `outbox_messages`, `inbox_messages` — high-volume append-only tables. (`idempotency_keys` was listed here and is not one: it is addressed by the natural key - `(tenant_id, key)` and has no surrogate id to generate. See Amendment 1.) + `(tenant_id, key)` and has no surrogate id to generate. See Amendment 4.) - App-side (`Guid.CreateVersion7()`) for aggregates that need the ID before `SaveChangesAsync()` to emit domain events / outbox writes referencing the new aggregate ID. diff --git a/docs/decisions/0031-postgresql-major-version.md b/docs/decisions/0031-postgresql-major-version.md index 9665c050..93d62c51 100644 --- a/docs/decisions/0031-postgresql-major-version.md +++ b/docs/decisions/0031-postgresql-major-version.md @@ -181,7 +181,7 @@ This ADR, and five documents repeating it, named PostgreSQL 18's native UUIDv7 generator **`gen_uuid_v7()`**. No such function exists. Measured against `postgres:18.4-alpine`: -``` +```sql SELECT gen_uuid_v7(); --> ERROR: function gen_uuid_v7() does not exist SELECT uuidv7(); --> 01a04366-8141-753d-a6a4-161239372fd0 ``` diff --git a/docs/decisions/0039-optimistic-concurrency-token.md b/docs/decisions/0039-optimistic-concurrency-token.md index 3f754fd5..44e4a14c 100644 --- a/docs/decisions/0039-optimistic-concurrency-token.md +++ b/docs/decisions/0039-optimistic-concurrency-token.md @@ -195,6 +195,7 @@ routing `SoftDelete` through the same stamp-and-increment primitive. | Database Standards / API Standards `**Derives from:**` headers | neither cites this ADR | | [21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md) | carries no entry for the rule below | | [Phase 02a § ADR commitments](../roadmap/phase-02a-kernel-tenancy.md) | does not list this ADR | + - **Phase 02a Packet 6** — the two infrastructure tables that carry no aggregate (`outbox_messages`, `idempotency_keys`) are not `IOptimisticConcurrency` entities and carry no `row_version`. Their write diff --git a/docs/decisions/0040-ambient-unit-of-work.md b/docs/decisions/0040-ambient-unit-of-work.md index b8e6c027..84f8f29c 100644 --- a/docs/decisions/0040-ambient-unit-of-work.md +++ b/docs/decisions/0040-ambient-unit-of-work.md @@ -84,6 +84,8 @@ public interface IUnitOfWork : IAsyncDisposable /// Joins the ambient transaction if one is active; otherwise opens it. /// Returns a handle whose Complete() is a no-op for a joiner — see § Nesting. + /// (The handle shipped as CompleteAsync / FailAsync / IsOwner; this sketch is + /// the accepted shape, and Amendment 2 records what changed and why.) Task BeginTransactionAsync(CancellationToken ct = default); /// Issues SET LOCAL app.tenant_id / app.organization_id / app.scope as the diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 09d9e3d7..09c20270 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -50,7 +50,7 @@ Accepted ADRs are not rewritten. A new decision is a new ADR, possibly supersedi | 0036 | [Trusted Inputs for Tenant and Organization Resolution](0036-tenant-resolution-trusted-inputs.md) | Resolution by **agreement, not priority** — every authoritative signal present is resolved independently and the request proceeds only on their intersection; no request header names a tenant or an organization, one header names a **host** over an authenticated hop and LearnStack still resolves it itself; `TenantContextOrigin` caps a host-only context to the `[PublicSurface]` read set; the platform-admin override leaves the resolution model | | 0037 | [What an Idempotency Key Identifies, Owns, and Replays](0037-idempotency-key-contract.md) | A client-chosen key is a **nonce inside a tenant's key space**, not an identity: `(tenant, key)` addresses the record and a fingerprint over organization, principal, method, path, query and body decides whether replaying it answers the question asked; a fencing token owns the claim; capacity is **admission, not eviction**, so nothing unexpired is ever displaced; the guarantee is at-most-once while a claim is live and at-least-once across process death | | 0038 | [Cross-Cutting Port and Event Contracts](0038-cross-cutting-port-and-event-contracts.md) | **Supersedes ADR-0014.** Retains Dapr behind demand gates; fixes the event envelope, handler isolation, trace/audit scope and cache contracts | -| 0039 | [The Optimistic Concurrency Token](0039-optimistic-concurrency-token.md) | An explicit `row_version bigint` (CLR `long`), project-wide, incremented inside `AuditableEntity` by the single primitive `MarkUpdated` and `SoftDelete` both route through, and mapped with `IsConcurrencyToken()` **alone** — `ValueGeneratedOnAddOrUpdate()` / `IsRowVersion()` make EF omit the column from the `UPDATE` entirely (Amendment 1, measured); `xmin` is rejected because the token is client-visible through ETag / `If-Match` and a dump-restore or logical-replication cutover changes it — measured, unlike the widely-cited `VACUUM FREEZE` objection, which does not | +| 0039 | [The Optimistic Concurrency Token](0039-optimistic-concurrency-token.md) | An explicit `row_version bigint` (CLR `long`), project-wide, incremented inside `AuditableEntity` by the single primitive `MarkUpdated` and `SoftDelete` both route through, and mapped with `HasDefaultValue(0L).IsConcurrencyToken().ValueGeneratedNever()` — exactly those three calls (Amendment 2): `ValueGeneratedOnAddOrUpdate()` / `IsRowVersion()` make EF omit the column from the `UPDATE` entirely (Amendment 1, measured), and `HasDefaultValue` alone leaves `ValueGenerated` at `OnAdd`, which the registered rule rejects; `xmin` is rejected because the token is client-visible through ETag / `If-Match` and a dump-restore or logical-replication cutover changes it — measured, unlike the widely-cited `VACUUM FREEZE` objection, which does not | | 0040 | [The Ambient Unit of Work](0040-ambient-unit-of-work.md) | One `DbConnection` per scope, owned by `IUnitOfWork`; every module `DbContext`, `IAuditStore` and `IOutbox` enlists on it, because `SET LOCAL` is connection-local and a context on its own connection reads **zero rows** under the corrected RLS policy. Spans one aggregate's write plus audit plus outbox plus any reads — cross-aggregate writes stay forbidden. Defines nesting, disposal, and the event-consumer entry point that never reaches MediatR | ## Superseded ADRs diff --git a/docs/glossary.md b/docs/glossary.md index 1c5cc920..c9ee1d86 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -117,7 +117,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | Term | Definition | |------|------------| -| **Tenant-owned table** | A database table whose every row belongs to one tenant, protected by an EF global query filter **and** a Row Level Security policy from the migration that creates it. Three sub-classes, per [Database Standards § Table classes](standards/05-database.md). | +| **Tenant-owned table** | A database table whose every row belongs to one tenant, protected by an EF global query filter **and** a Row Level Security policy from the migration that creates it — both layers, always, once the pair exists. As of Packet 6 the policy is live on every such table and the filters are not: they land in Packet 7 with `TenantResolverMiddleware`, and until then RLS carries the invariant alone, fail-closed. Three sub-classes, per [Database Standards § Table classes](standards/05-database.md). | | **Tenant-owned, tenant-wide** | The ordinary sub-class: a `tenant_id` column, one `AND`-ed policy keyed on it. | | **Tenant-owned, organization-scoped** | Adds a nullable `organization_id`, the `app.scope` read hatch, and two `AS RESTRICTIVE` write guards. | | **Tenant-owned, self-keyed** | `tenants` itself. It has **no** `tenant_id` column — its `id` *is* the tenant id, so its policy keys on `id`. | diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index b2ec38a5..f0918228 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -100,8 +100,14 @@ stateDiagram-v2 Archived --> [*] : retained for audit, never served ``` -Tenant: `Trial → Active → Suspended ⇄ Active → Archived`. `Archived` is terminal -for serving; rows are retained for audit and retention obligations. +Text fallback — **Tenant lifecycle**: + +- `Trial` on provisioning; `Active` on first payment, or immediately for a plan + needing none. +- `Active ⇄ Suspended` — billing failure or policy breach suspends; resolution + restores. +- `Archived` from either, and it is terminal **for serving**: rows are retained + for audit and retention obligations, never served. ```mermaid stateDiagram-v2 @@ -114,10 +120,15 @@ stateDiagram-v2 Failed --> Verifying : retried ``` -TenantDomain: a `Subdomain` is created already `Verified` because the platform -controls the zone; only a `Custom` domain travels the whole path. **A verified -row does not serve traffic on its own** — a corresponding -`platform_host_to_tenant` row with `is_publicly_live` does. +Text fallback — **TenantDomain lifecycle**: + +- A `Subdomain` is created already `Verified`: the platform controls the zone, so + there is nothing to prove. The aggregate refuses `MarkVerified` / + `MarkVerificationFailed` on one. +- A `Custom` domain travels the whole path — `Requested → Verifying → Verified`, + or `Verifying → Failed → Verifying` on retry. +- **A verified row does not serve traffic on its own.** A corresponding + `platform_host_to_tenant` row with `is_publicly_live` does. ## Sequence diagrams @@ -144,6 +155,13 @@ sequenceDiagram U->>P: COMMIT ``` +Text fallback — **provisioning a tenant**: the registry (Hub, config or fixture) +supplies the tenant id, slug and display name; the handler opens the ambient +transaction through `IUnitOfWork`, sets `app.tenant_id` to that id as the first +statement inside it, inserts `tenants`, inserts the default `organizations` row, +updates `tenants.default_organization_id`, and commits. One transaction, one +connection, one commit point. + Three statements, one transaction. The tenant id is **never minted in the handler**: the registry assigns it and the transaction sets `app.tenant_id` to that value *before* the insert, so the self-keyed policy's `WITH CHECK` passes. A @@ -169,6 +187,12 @@ sequenceDiagram O-->>C: invalidate the resolver cache entry ``` +Text fallback — **host mapping changed**: the Hub `PUT`s host mappings to +`/api/internal/tenants/{id}/host-mappings`; the endpoint upserts +`platform_host_to_tenant` and enqueues `learnstack.hub.custom-domain.activated` +into `outbox_messages` **on the same transaction** — the outbox row is the +boundary — and the dispatched event invalidates the resolver's cache entry. + `IHostToTenantResolver` **never calls the Hub**: an anonymous page load must not depend on a control plane being reachable ([ADR-0034](../../decisions/0034-hub-contract-surface-invariant.md)). The @@ -196,6 +220,13 @@ flowchart LR OTHER -.->|application contract only| APP ``` +Text fallback — **components**: Tenancy is three assemblies — `Domain` (the +`Tenant` and `Organization` aggregates), `Application`, and `Infrastructure` +(`TenancyDbContext`). `Domain` depends on `SharedKernel` for `TenantId`, +`OrganizationId` and `IUnitOfWork`; `Application` on `Domain`; `Infrastructure` +on `Application` and on PostgreSQL. The Hub adapters (`IEntitlementProvider`, +`IHubTenantSync`) and every other module reach `Application` and nothing deeper. + Other modules reach Tenancy **only** through an application contract in `LearnStack.Modules.Tenancy.Application.Contracts` — never a navigation property, never a cross-module join diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 2580eee7..7753ed83 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -989,8 +989,11 @@ lives in [15-event-and-outbox.md](../architecture/15-event-and-outbox.md). ## Idempotency -`idempotency_keys` is the durable `IIdempotencyStore` -([ADR-0037](../decisions/0037-idempotency-key-contract.md)). Every column below +`idempotency_keys` is the schema the durable `IIdempotencyStore` will read and +write ([ADR-0037](../decisions/0037-idempotency-key-contract.md)). The table and +the store ship apart, per Amendment 1: the schema is a one-way door and shipped +in Packet 6; the store is additive and ships on its ADR-0035 trigger, with +`InMemoryIdempotencyStore` registered until then. Every column below is forced by the shipped port (`LearnStack.SharedKernel/Idempotency/IIdempotencyStore.cs`) rather than chosen: `fingerprint` by `TryClaimAsync`'s third parameter and the `Mismatched` diff --git a/docs/standards/README.md b/docs/standards/README.md index e999e356..20030316 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -100,7 +100,7 @@ enforced" change in one commit. Until that lands, **this table wins**. | 18 | [Audit Coverage](18-audit-coverage.md) | **Adopted** | `AuditLogBehavior` is a shell and `audit_log` does not exist. Lands in Packet 9 under [ADR-0033](../decisions/0033-audit-durability-model.md). | | 19 | [Permissions](19-permissions.md) | **Adopted** | No permission key, policy or role exists. Lands in [Phase 03](../roadmap/phase-03-identity-admin.md). | | 20 | [Infrastructure Stack](20-infrastructure-stack.md) | **Adopted** | `ISecretProvider` shipped in Packet 3 and `DeploymentMode` branching is real, but the ports land in Packet 5 and the Dapr / Kafka / APISIX / Vault adapters are demand-gated to [Phase 11](../roadmap/phase-11-production-hardening.md) per [ADR-0035](../decisions/0035-demand-gated-infrastructure.md). | -| 21 | [Architecture Tests Catalogue](21-architecture-tests-catalogue.md) | **Active** | Twenty-nine test methods run in the architecture assembly and seven further implemented rules run beside it, four of them against an applied schema; the catalogue's own per-row status column distinguishes those from the registered-but-unimplemented majority. | +| 21 | [Architecture Tests Catalogue](21-architecture-tests-catalogue.md) | **Active** | Twenty-nine test methods run in the architecture assembly and seven further implemented rules run beside it, three of them against an applied schema; the catalogue's own per-row status column distinguishes those from the registered-but-unimplemented majority. | Thirteen `Active`, nine `Adopted`. That split is the honest picture of a platform whose foundation is real and whose domain has not been written yet — and it is far more useful diff --git a/infra/compose/postgres-init/02-create-roles.sql b/infra/compose/postgres-init/02-create-roles.sql index 14904867..15b36bb8 100644 --- a/infra/compose/postgres-init/02-create-roles.sql +++ b/infra/compose/postgres-init/02-create-roles.sql @@ -50,6 +50,23 @@ SELECT format('CREATE ROLE learnstack_outbox_admin LOGIN PASSWORD %L BYPASSRLS', WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'learnstack_outbox_admin') \gexec +-- Then CONVERGE, unconditionally. The creates above fire only when the role is +-- absent, so on a cluster that already has them — the documented recovery path +-- is "apply this file by hand, it is idempotent" — a changed password in `.env` +-- would have had no effect at all, and `make migrate` would keep failing with +-- `password authentication failed` while the script reported success. +-- +-- The bypass attribute is re-asserted for the same reason and a sharper one: it +-- is the security-critical half. A role that acquired BYPASSRLS out of band makes +-- every policy in the database inert, and re-running this file is the cheapest +-- way to put it back. (The runtime refuses to start against such a role anyway — +-- the composition root asks the server `rolbypassrls OR rolsuper` on every +-- physical connection — but this is what fixes it rather than reporting it.) +ALTER ROLE learnstack_migration WITH LOGIN PASSWORD :'migration_pw' NOBYPASSRLS; +ALTER ROLE learnstack_app WITH LOGIN PASSWORD :'app_pw' NOBYPASSRLS; +ALTER ROLE learnstack_platform WITH LOGIN PASSWORD :'platform_pw' BYPASSRLS; +ALTER ROLE learnstack_outbox_admin WITH LOGIN PASSWORD :'outbox_pw' BYPASSRLS; + -- No `GRANT learnstack_platform TO learnstack_app`, ever. Membership would make -- BYPASSRLS a standing capability of the application role, reachable from any -- code path that can execute `SET ROLE` — and a plain SET ROLE survives COMMIT on From 7081294ca5ac6c5577650dcd0a18bdcdc2fd92c5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 19:26:23 +0300 Subject: [PATCH 24/35] fix(review): close the second review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five of the eight are code, and three of those are guards that could not fail. **The roles script was idempotent but not convergent, and the gap had a name.** It re-asserted `BYPASSRLS` and the password but not `SUPERUSER` — the one attribute the whole isolation model cannot see, because a superuser bypasses row security with `rolbypassrls = false`. So a role escalated out of band kept the escalation while the column every isolation assertion reads still said the right thing. All four roles now converge on `NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION`, and `TheRolesScriptTakesBackAnEscalatedAttribute` grants `SUPERUSER` for real, re-runs the script, and asserts it was taken away — it fails without the keyword. **`Organization_Aggregate_Declared_In_Tenancy_Domain` accepted zero declarations.** `HaveCountLessThanOrEqualTo(1)` is right for `OrganizationBranding`, which ships in Phase 06, and wrong for `Organization`, which exists: deleting the aggregate would have left the rule green. The theory now carries which of the two it is. **A rollback that throws stranded the connection.** `DisposeAsync` called `RollbackCoreAsync` and then disposed the connection — in sequence, so a throwing rollback (a connection already broken by the failure being cleaned up after is the ordinary way) skipped the disposal and left the connection outside the pool for the life of the process. It is a `finally` now. **The commit-hygiene check could not fail on a bad range.** `git log` inside `< <(...)` cannot fail the step — `pipefail` does not reach a process substitution — so an unresolvable `BASE_SHA` fed the loop nothing and the check reported success on a range it never read. Materialized to a file first, status checked; a file rather than a variable because the records are NUL-separated and command substitution drops NUL bytes. The remaining text bounds are now guarded where the value is set — `locale` at 35, both `key` columns at 200 — through one shared helper the `Organization` guard also uses, rather than two implementations of the same idea. Three documentation fixes: the durable `IIdempotencyStore`'s trigger is named by ADR-0037 Amendment 1, not by ADR-0035, which is only the mechanism; `run-tests-locally` was troubleshooting Valkey, LiveKit, Dapr and three ports the fixture never touches; and a duplicated XML summary sat on a request record. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-integration-test/SKILL.md | 6 +- .claude/skills/run-tests-locally/SKILL.md | 14 ++--- .github/workflows/ci.yml | 16 +++++- .../Persistence/NpgsqlUnitOfWork.cs | 20 +++++-- .../CompositeKeyedEntities.cs | 24 ++++++++ .../Organization.cs | 25 ++------ .../TenantSetting.cs | 1 + .../TenancyConventionTests.cs | 26 +++++++-- .../Database/DatabaseRoleTests.cs | 41 +++++++++++++ .../Database/UnitOfWorkTests.cs | 4 -- .../Modules/Tenancy/TenancyAggregateTests.cs | 57 +++++++++++++++++++ .../compose/postgres-init/02-create-roles.sql | 19 +++++-- 12 files changed, 203 insertions(+), 50 deletions(-) diff --git a/.claude/skills/add-integration-test/SKILL.md b/.claude/skills/add-integration-test/SKILL.md index 7b7366ff..8f3f87e9 100644 --- a/.claude/skills/add-integration-test/SKILL.md +++ b/.claude/skills/add-integration-test/SKILL.md @@ -201,8 +201,10 @@ deleted with the suite green — measured, in Packet 6. See > **Steps 3 to 5 are the shape, not today's API.** `IOutbox`, the outbox > dispatcher and `audit_log` do not exist yet — Phase 02b owns the first two, -> Packet 9 the third — and the durable `IIdempotencyStore` ships on its ADR-0035 -> trigger. The `_fx.*` members below are illustrative of what those phases will +> Packet 9 the third — and the durable `IIdempotencyStore` ships on the trigger +> [ADR-0037 Amendment 1](../../../docs/decisions/0037-idempotency-key-contract.md) +> names: the first `[Idempotent]` endpoint, or the first deployment running more +> than one instance. Packet 6 shipped the `idempotency_keys` table, not the store. The `_fx.*` members below are illustrative of what those phases will > provide; today the only fixtures are `PostgresFixture` and `SchemaFixture`, and > the only session-context helper is `SchemaQueries`. Write against Step 1 and > Step 2's shapes until the owning phase lands. diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index 7ede44c5..4c4bff2d 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -34,7 +34,7 @@ flags, and a triage map for the most common failure shapes. |-------|----------|-------------| | Suite | Yes | `unit` / `integration` / `architecture` / `e2e`. | | Filter | No | `--filter ` to run a subset. | -| Docker available? | Integration / E2E | Required for Testcontainers + LiveKit. | +| Docker available? | Integration (`Requires=Docker`) / E2E | A running daemon. Postgres only — no Valkey, no Kafka. | ## Workflow @@ -129,8 +129,8 @@ Common failure shapes: | Test passes locally, fails in CI | Race condition; check `await` chains. | | Empty result where rows should exist | No `app.tenant_id` on this transaction, or the wrong one — RLS working as designed. Set it with `SchemaQueries.SetTenantAsync` as the transaction's first statement. | | `relation "
" does not exist` | Migration didn't apply; check the module's `Persistence/Migrations`. | -| Docker container fails to start | Port collision (5432, 6379) — stop local Postgres / Valkey. | -| Test hangs | A handler awaiting Dapr in the dev fallback path; check `IEventBus` registration. | +| Docker container fails to start | Port collision on 5432 — stop a local Postgres. Testcontainers maps a random host port, so this only bites when something else already holds the container port. | +| `password authentication failed` | The four roles are provisioned by the fixture from `infra/compose/postgres-init/02-create-roles.sql`; a failure there fails the fixture, not one test. | ### Step 6: Run frontend tests @@ -219,8 +219,7 @@ dotnet test --blame-hang --blame-hang-timeout 5min - The relevant suite passes locally with the same `dotnet --version` and `pnpm --version` CI uses. -- For integration suites, Docker is running; no port collisions on 5432 / 6379 / - 9092 / 8200. +- For integration suites, Docker is running and nothing else holds 5432. - A failing test message points at the specific rule / scenario it violates. - For frontend changes, `pnpm test:a11y` is clean — `axe-core` violations fail the test by design. @@ -232,8 +231,9 @@ dotnet test --blame-hang --blame-hang-timeout 5min change — the lockfile must match. - **Skipped architecture test.** Forbidden. If a test is marked `[Skip]`, treat it as a bug. -- **Port collisions.** Local Postgres / Valkey on default ports collides with - Testcontainers. Stop them. +- **Port collisions.** A local Postgres on 5432 collides with the fixture's + container. Stop it, or let Testcontainers pick the host port (it does) and stop + publishing 5432 from `make dev`. - **`--no-build` after a source change.** Drop the flag — the test would run against stale binaries. - **Ignoring `axe-core` failures.** Accessibility violations are blocking per diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d56da7a..fda0160f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -311,10 +311,24 @@ jobs: run: | set -euo pipefail fail=0 + + # Materialized to a file FIRST, so the exit status is checked. Inside + # `< <(...)` a failing `git log` cannot fail the step — `pipefail` does + # not reach a process substitution — so an unresolvable BASE_SHA would + # feed the loop nothing and the check would report success on a range it + # never read. A file rather than a variable because the records are + # NUL-separated and command substitution drops NUL bytes. + # # `-z` with `%s`: the subject alone, NUL-TERMINATED, because a subject # can contain anything and because `%s%x00` leaves the record's trailing # newline attached to the NEXT subject — measured, every length came back # one too high and the two real violations reported 78 and 82. + if ! git log -z --no-merges --format='%s' \ + "${BASE_SHA}..${HEAD_SHA}" > "${RUNNER_TEMP}/subjects"; then + echo "::error::could not read ${BASE_SHA}..${HEAD_SHA}" + exit 1 + fi + while IFS= read -r -d '' subject; do length=${#subject} if [ "$length" -gt 72 ]; then @@ -326,7 +340,7 @@ jobs: echo "::error::subject is not Conventional Commits: ${subject}" fail=1 fi - done < <(git log -z --no-merges --format='%s' "${BASE_SHA}..${HEAD_SHA}") + done < "${RUNNER_TEMP}/subjects" if [ "$fail" -ne 0 ]; then echo "Standards 14 § Commit messages: type(scope): subject, imperative, <= 72 chars." exit 1 diff --git a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs index 028bbbdf..f0a677da 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs @@ -179,15 +179,23 @@ public async ValueTask DisposeAsync() // would commit work nobody claimed was finished. var swallowedCommit = _commitRequested && _transaction is not null; - if (_transaction is not null) + try { - await RollbackCoreAsync(); + if (_transaction is not null) + { + await RollbackCoreAsync(); + } } - - if (_connection is not null) + finally { - await _connection.DisposeAsync(); - _connection = null; + // In a finally: a rollback that throws — a connection already broken + // by the failure being cleaned up after is the ordinary way — must not + // strand the connection outside the pool for the rest of the process. + if (_connection is not null) + { + await _connection.DisposeAsync(); + _connection = null; + } } if (swallowedCommit) diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs index b9d824a5..23fc88c1 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs @@ -45,6 +45,7 @@ public static TenantLocale Create( TenantId tenantId, string locale, bool isDefault, bool isEnabled = true, short sort = 0) { ArgumentException.ThrowIfNullOrWhiteSpace(locale); + MappedLength.EnsureAtMost(locale, 35, nameof(locale)); if (!tenantId.IsInitialized()) { @@ -104,6 +105,7 @@ public static TenantFeatureFlag Create( TenantId tenantId, string key, string value, DateTimeOffset at, UserId by) { ArgumentException.ThrowIfNullOrWhiteSpace(key); + MappedLength.EnsureAtMost(key, 200, nameof(key)); JsonValue.EnsureWellFormed(value, nameof(value)); if (!tenantId.IsInitialized()) @@ -131,6 +133,28 @@ public void SetValue(string value, DateTimeOffset at, UserId by) } } +/// +/// Guards a value against the length its column holds. +/// +/// +/// The database rejects a longer value with 22001, which names neither the +/// property nor the aggregate and arrives three layers from the call that produced +/// it. The numbers here are the ones the EF configurations map; asserting them at +/// the factory is what makes the failure say which field is wrong. +/// +internal static class MappedLength +{ + public static void EnsureAtMost(string value, int maximum, string parameterName) + { + if (value.Length > maximum) + { + throw new ArgumentException( + $"The value is {value.Length} characters; the column holds {maximum}.", + parameterName); + } + } +} + /// /// Guards a value on its way into a jsonb column. /// diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs index 703d1e56..882ec387 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs @@ -155,30 +155,13 @@ public void Rename(string displayName, IClock clock, UserId updatedBy) } /// - /// The two bounds the EF configuration maps, asserted where the value is set. + /// The two bounds OrganizationConfiguration maps: 63 for the slug, + /// which is a DNS label, and 200 for the display name. /// - /// - /// The database rejects a longer value with 22001, three layers from - /// the call that produced it and with no property name. These are the same - /// numbers OrganizationConfiguration declares — 63 for the slug, which - /// is a DNS label, and 200 for the display name — and they are asserted here - /// so the failure names what is wrong. - /// private static void EnsureWithinMappedLengths(string slug, string displayName) { - if (slug.Length > 63) - { - throw new ArgumentException( - $"Slug is {slug.Length} characters; the column holds 63, the DNS label limit.", - nameof(slug)); - } - - if (displayName.Length > 200) - { - throw new ArgumentException( - $"DisplayName is {displayName.Length} characters; the column holds 200.", - nameof(displayName)); - } + MappedLength.EnsureAtMost(slug, 63, nameof(slug)); + MappedLength.EnsureAtMost(displayName, 200, nameof(displayName)); } /// Moves the organization to a new lifecycle state. diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs index 4af0872b..414fe861 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs @@ -67,6 +67,7 @@ public static TenantSetting Create( { ArgumentNullException.ThrowIfNull(clock); ArgumentException.ThrowIfNullOrWhiteSpace(key); + MappedLength.EnsureAtMost(key, 200, nameof(key)); JsonValue.EnsureWellFormed(value, nameof(value)); if (!id.IsInitialized()) diff --git a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs index cd6afab2..9db20431 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs @@ -99,9 +99,15 @@ public void Assertion_Budget_Does_Not_Depend_On_ICacheService() } [Theory] - [InlineData("Organization")] - [InlineData("OrganizationBranding")] - public void Organization_Aggregate_Declared_In_Tenancy_Domain(string typeName) + // `required: true` for the type that exists — a rule accepting zero + // declarations of `Organization` would stay green if the aggregate were + // deleted, which is the vacuity this catalogue calls out generally. + // `OrganizationBranding` is genuinely zero-or-one: it ships with the token + // merge in Phase 06, and stating the rule now is what stops the first one + // landing in the wrong module. + [InlineData("Organization", true)] + [InlineData("OrganizationBranding", false)] + public void Organization_Aggregate_Declared_In_Tenancy_Domain(string typeName, bool required) { // ADR-0017's original sample put Organization in Identity; Amendment 2 // moved it to Tenancy, and Identity now holds OrganizationId by value and @@ -123,8 +129,18 @@ public void Organization_Aggregate_Declared_In_Tenancy_Domain(string typeName) .Select(type => $"{type.FullName} in {assembly.GetName().Name}")) .ToList(); - declarations.Should().HaveCountLessThanOrEqualTo(1, - $"{typeName} is declared once across every module Domain assembly (ADR-0017 Amendment 2)"); + if (required) + { + declarations.Should().ContainSingle( + $"{typeName} is declared exactly once across every module Domain " + + "assembly (ADR-0017 Amendment 2)"); + } + else + { + declarations.Should().HaveCountLessThanOrEqualTo(1, + $"{typeName} does not exist yet; when it does it is declared once, " + + "in Tenancy (ADR-0017 Amendment 2)"); + } declarations .Where(d => !d.EndsWith("LearnStack.Modules.Tenancy.Domain", StringComparison.Ordinal)) diff --git a/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs index 559c5f1b..0c882b14 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs @@ -264,6 +264,47 @@ public async Task TheRolesScriptIsIdempotent() result.ExitCode.Should().Be(0, "a re-run must be a no-op, not an error: {0}", result.Stderr); } + [Fact] + public async Task TheRolesScriptTakesBackAnEscalatedAttribute() + { + // Idempotent is not the same as convergent, and the script was only the + // first. `CREATE ROLE` fires when the role is absent, so on an existing + // cluster — where "apply this file by hand" is the documented recovery + // path — nothing was re-asserted: a changed password had no effect, and a + // role that had acquired SUPERUSER out of band kept it. + // + // SUPERUSER specifically, because it is the one that cannot be seen in the + // attribute this model is built on: a superuser bypasses row security with + // `rolbypassrls = false`, so every policy in the database is inert while + // the column the isolation suite reads still says the right thing. + try + { + await _postgres.ExecuteAsSuperuserAsync("ALTER ROLE learnstack_app SUPERUSER"); + + (await ReadIsSuperuserAsync()).Should().BeTrue("the escalation must be real for the test to mean anything"); + + var result = await _postgres.RunRolesScriptAgainAsync(); + result.ExitCode.Should().Be(0, "{0}", result.Stderr); + + (await ReadIsSuperuserAsync()).Should().BeFalse( + "re-running the script converges the role back to what it declares"); + } + finally + { + await _postgres.ExecuteAsSuperuserAsync("ALTER ROLE learnstack_app NOSUPERUSER"); + } + } + + private async Task ReadIsSuperuserAsync() + { + await using var connection = await PostgresFixture.OpenAsync(_postgres.MigrationConnectionString); + await using var command = new NpgsqlCommand( + "SELECT rolsuper FROM pg_roles WHERE rolname = 'learnstack_app'", + (NpgsqlConnection)connection); + + return (bool)(await command.ExecuteScalarAsync())!; + } + [Fact] public async Task NoDefaultPrivilegesExist_SoAnUngrantedTableIsAlwaysLoud() { diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index a898b246..54986a68 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -571,10 +571,6 @@ private static async Task ExecuteAsync( private static StubTenantContext Resolved(Guid tenant, Guid organization) => new(tenant, organization); - /// - /// A resolved context, standing in for what Packet 7's - /// TenantResolverMiddleware will populate. - /// /// A request type for driving the real behavior. public sealed record Probe : MediatR.IRequest>; diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs index aab3779c..6bfea2a3 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs @@ -226,6 +226,63 @@ public void An_organization_is_bounded_by_the_lengths_its_columns_hold( } } + [Theory] + // Every mapped text bound, asserted where the value is set. The database + // reports 22001 with no property name, three layers from the call. + [InlineData(35, true)] + [InlineData(36, false)] + public void A_locale_is_bounded_by_the_length_its_column_holds(int length, bool accepted) + { + var create = () => TenantLocale.Create(Tenant, new string('l', length), isDefault: true); + + if (accepted) + { + create.Should().NotThrow(); + } + else + { + create.Should().Throw().WithMessage("*the column holds 35*"); + } + } + + [Theory] + [InlineData(200, true)] + [InlineData(201, false)] + public void A_setting_key_is_bounded_by_the_length_its_column_holds(int length, bool accepted) + { + var settingId = TenantSettingId.From(Guid.Parse("55555555-2222-7222-8222-222222222222")); + + var create = () => TenantSetting.Create( + settingId, Tenant, null, new string('k', length), "true", Clock, Actor); + + if (accepted) + { + create.Should().NotThrow(); + } + else + { + create.Should().Throw().WithMessage("*the column holds 200*"); + } + } + + [Theory] + [InlineData(200, true)] + [InlineData(201, false)] + public void A_feature_flag_key_is_bounded_by_the_length_its_column_holds(int length, bool accepted) + { + var create = () => TenantFeatureFlag.Create( + Tenant, new string('k', length), "true", Clock.UtcNow, Actor); + + if (accepted) + { + create.Should().NotThrow(); + } + else + { + create.Should().Throw().WithMessage("*the column holds 200*"); + } + } + /// /// An identifier nobody assigned, obtained the only way that compiles. /// diff --git a/infra/compose/postgres-init/02-create-roles.sql b/infra/compose/postgres-init/02-create-roles.sql index 15b36bb8..7ad11215 100644 --- a/infra/compose/postgres-init/02-create-roles.sql +++ b/infra/compose/postgres-init/02-create-roles.sql @@ -62,10 +62,21 @@ WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'learnstack_outbox_admin' -- way to put it back. (The runtime refuses to start against such a role anyway — -- the composition root asks the server `rolbypassrls OR rolsuper` on every -- physical connection — but this is what fixes it rather than reporting it.) -ALTER ROLE learnstack_migration WITH LOGIN PASSWORD :'migration_pw' NOBYPASSRLS; -ALTER ROLE learnstack_app WITH LOGIN PASSWORD :'app_pw' NOBYPASSRLS; -ALTER ROLE learnstack_platform WITH LOGIN PASSWORD :'platform_pw' BYPASSRLS; -ALTER ROLE learnstack_outbox_admin WITH LOGIN PASSWORD :'outbox_pw' BYPASSRLS; +-- NOSUPERUSER on all four, and it is not decoration: a SUPERUSER bypasses row +-- security whatever `rolbypassrls` says — measured — so a superuser +-- learnstack_app makes every policy in the database inert while the attribute +-- this script converges still reads `f`. NOCREATEDB / NOCREATEROLE / +-- NOREPLICATION for the same reason in miniature: none of the four needs any of +-- them, learnstack_migration owns tables rather than databases or roles, and an +-- attribute nothing needs is one an escalation can use. +ALTER ROLE learnstack_migration WITH LOGIN PASSWORD :'migration_pw' + NOBYPASSRLS NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION; +ALTER ROLE learnstack_app WITH LOGIN PASSWORD :'app_pw' + NOBYPASSRLS NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION; +ALTER ROLE learnstack_platform WITH LOGIN PASSWORD :'platform_pw' + BYPASSRLS NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION; +ALTER ROLE learnstack_outbox_admin WITH LOGIN PASSWORD :'outbox_pw' + BYPASSRLS NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION; -- No `GRANT learnstack_platform TO learnstack_app`, ever. Membership would make -- BYPASSRLS a standing capability of the application role, reachable from any From 47262674f90af05e541a2d78e87161827b6a9d84 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 20:55:18 +0300 Subject: [PATCH 25/35] fix(kernel): close the escalation, the leak and the unswept policies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review round's three blockers and the code half of what it confirmed underneath them. Membership, not attributes. `GRANT learnstack_platform TO learnstack_app` leaves learnstack_app's own rolbypassrls false and still lets it SET ROLE into a BYPASSRLS role — measured, directly and through a bridge role. The runtime guard now asks pg_has_role, which follows the whole chain, and the roles script revokes every membership the four roles hold rather than only converging their attributes. Two credential paths echoed what they were meant to hide. `make migrate` knew `Username=` and `Password=` and nothing else, so a `.env` written with Npgsql's `UID=` / `PWD=` aliases read the role as empty and printed the password; the parse-failure branch handed back a `postgres://user:pw@host` URI whole, which is the one form Npgsql rejects outright and therefore the one that reaches it. Both keyword tables now come from the same file. And the foreign-tenant write sweep covered three of nine tables while claiming all of them: `WITH CHECK (true)` on tenant_locales passed the entire suite. The cases are now audited against pg_policies. Also here, each proven by deleting the code it covers: a best-effort rollback that cannot outrank the exception it is cleaning up after; a generation on the unit-of-work frame, so a handle left over from a committed unit cannot commit or discard the next one; a container-scoped DbContext registration marker instead of process-global state; the literal-aware comment stripper the sibling rule already used; and the Tenancy aggregates' missing invariants — status transition tables, slug shape, BCP-47 well-formedness, the audit-input pair on the one entity that skipped it, and a reachable Verifying state. Two tests were agreeing with a gap rather than constraining it: one pinned a 35-character run of 'l' as a valid locale, the other walked Requested → Verified in a single call. ADR: 0003, 0037, 0040 Co-Authored-By: Claude Opus 5 (1M context) --- Makefile | 9 +- .../PersistenceCompositionExtensions.cs | 60 ++++- .../Pipeline/TransactionBehavior.cs | 33 ++- .../ModuleDbContextRegistration.cs | 62 +++-- .../Persistence/NpgsqlUnitOfWork.cs | 37 ++- .../Domain/AuditableEntity.cs | 29 ++- .../CompositeKeyedEntities.cs | 105 +++++++- .../Organization.cs | 40 +++- .../Tenant.cs | 53 +++- .../TenantDomain.cs | 55 ++++- .../TenantSetting.cs | 17 +- .../PersistenceConventionTests.cs | 123 +++++++++- .../Database/DatabaseRoleTests.cs | 74 ++++++ .../Database/TenancySchemaTests.cs | 160 +++++++++++-- .../Database/UnitOfWorkTests.cs | 125 +++++++++- .../ApplicationDataSourceGuardTests.cs | 8 +- .../Pipeline/TransactionBehaviorTests.cs | 61 ++++- .../Modules/Tenancy/TenancyAggregateTests.cs | 226 +++++++++++++++++- .../0037-idempotency-key-contract.md | 21 +- docs/decisions/0040-ambient-unit-of-work.md | 17 +- ...cting-false-statements-in-accepted-adrs.md | 159 ++++++++++++ .../compose/postgres-init/02-create-roles.sql | 18 ++ scripts/connection-string.awk | 84 +++++++ 23 files changed, 1444 insertions(+), 132 deletions(-) create mode 100644 docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md create mode 100644 scripts/connection-string.awk diff --git a/Makefile b/Makefile index 213e4480..f639f739 100644 --- a/Makefile +++ b/Makefile @@ -166,9 +166,8 @@ migrate: ## Apply every EF migration chain (platform + each module) as `learnsta migration_cs=$$(sed -n "s/^ConnectionStrings__Migration=//p" .env \ | tail -1 | tr -d "\r" | sed "s/^['\"]//; s/['\"]$$//"); \ fi; \ - role=$$(printf '%s' "$$migration_cs" | tr ';' '\n' \ - | sed -n 's/^[[:space:]]*[Uu][Ss][Ee][Rr][Nn][Aa][Mm][Ee][[:space:]]*=[[:space:]]*//p' | head -1); \ - redacted=$$(printf '%s' "$$migration_cs" | sed "s/[Pp]assword[[:space:]]*=[^;]*/Password=***/g"); \ + role=$$(printf '%s' "$$migration_cs" | awk -f scripts/connection-string.awk -v field=user); \ + redacted=$$(printf '%s' "$$migration_cs" | awk -f scripts/connection-string.awk -v field=redacted); \ if [ -z "$$migration_cs" ]; then \ echo "ConnectionStrings__Migration is not set."; \ echo "It arrives with the four-role model in Phase 02a Packet 6: copy the"; \ @@ -182,7 +181,9 @@ migrate: ## Apply every EF migration chain (platform + each module) as `learnsta echo " $$redacted"; \ echo "Migrations must run as the role that OWNS every table. Running them as"; \ echo "the runtime role is the arrangement FORCE ROW LEVEL SECURITY defeats."; \ - echo "If the value looks truncated at the first ';', quote it in .env."; \ + echo "If the value looks truncated at the first ';', quote it in .env. A"; \ + echo "URI-style DSN (postgres://...) is not a form Npgsql parses at all; the"; \ + echo "expected shape is the key/value list in .env.example."; \ exit 1; \ fi; \ export ConnectionStrings__Migration="$$migration_cs"; \ diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 30f84ab9..69f34f32 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -151,8 +151,21 @@ internal static NpgsqlDataSource BuildApplicationDataSource(string? connectionSt private static async Task RefuseBypassRole(NpgsqlConnection connection, bool async) { await using var command = connection.CreateCommand(); + + // Reachability, not the role's own two attributes. `GRANT + // learnstack_platform TO learnstack_app` leaves `rolbypassrls` and + // `rolsuper` false on learnstack_app and still lets it `SET ROLE` into a + // BYPASSRLS role — measured, directly and through a bridge role that holds + // the membership on its behalf. `pg_has_role(..., 'MEMBER')` follows the + // whole chain and includes the role itself, so this subsumes the attribute + // check rather than sitting beside it. command.CommandText = - "SELECT rolbypassrls OR rolsuper FROM pg_roles WHERE rolname = current_user"; + """ + SELECT EXISTS ( + SELECT 1 FROM pg_roles r + WHERE (r.rolbypassrls OR r.rolsuper) + AND pg_has_role(current_user, r.oid, 'MEMBER')) + """; var bypasses = async ? await command.ExecuteScalarAsync() @@ -161,11 +174,13 @@ private static async Task RefuseBypassRole(NpgsqlConnection connection, bool asy if (bypasses is true) { throw new InvalidOperationException( - $"The runtime connected as a role that bypasses Row Level Security " - + $"(rolbypassrls or rolsuper). Every policy in the database is then inert and " - + "every query crosses every tenant boundary. Check ConnectionStrings:Default " - + "and the grants on the role it names; EnterPlatformAdminScope is the only " - + "sanctioned path to a bypass credential."); + "The runtime connected as a role that can reach one which bypasses Row Level " + + "Security — by holding rolbypassrls or rolsuper itself, or by being a member " + + "of a role that does, directly or through another. Every policy in the " + + "database is then one SET ROLE away from inert. Check " + + "ConnectionStrings:Default and the role memberships granted to the role it " + + "names; EnterPlatformAdminScope is the only sanctioned path to a bypass " + + "credential."); } } @@ -196,14 +211,35 @@ private static string Redact(NpgsqlConnectionStringBuilder parsed) /// clear a field on. /// /// - /// A regex is the only tool left here, so it covers every alias Npgsql accepts - /// rather than the canonical spelling alone, and it does not have to be - /// exhaustive to be safe: the value it is redacting is one Npgsql rejected, so - /// nothing downstream will treat it as a credential. + /// + /// A regex is the only tool left here, so it covers both forms a rejected value + /// arrives in. The keyword pass knows every alias Npgsql accepts — + /// Password, PSW, PWD, measured, not the canonical + /// spelling alone. + /// + /// + /// The second pass is the one this branch exists for. Npgsql rejects a + /// URI-style DSN outright — measured — so postgres://user:secret@host/db + /// is not some exotic input here, it is the input, and it carries its password + /// in the userinfo where no password= appears. The first version of this + /// method echoed it whole into an exception message that a startup failure puts + /// in the log. + /// /// - private static string RedactUnparsed(string connectionString) => - System.Text.RegularExpressions.Regex.Replace( + private static string RedactUnparsed(string connectionString) + { + var byKeyword = System.Text.RegularExpressions.Regex.Replace( connectionString, "(?i)\\b(password|pwd|psw)(\\s*=)[^;]*", "$1$2***"); + + // The whole userinfo, not the password half: a value shaped like a URI + // failed to parse, so there is no field to be confident about, and the + // username is not what the operator needs from this message anyway — the + // message tells them the form is wrong, not which role they named. + return System.Text.RegularExpressions.Regex.Replace( + byKeyword, + "(?i)(://)[^/@\\s]*@", + "$1***@"); + } } diff --git a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs index 8e643fc8..03ac8991 100644 --- a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs @@ -2,6 +2,7 @@ using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using MediatR; +using Microsoft.Extensions.Logging; namespace LearnStack.Application.Pipeline; @@ -63,7 +64,8 @@ namespace LearnStack.Application.Pipeline; /// public sealed class TransactionBehavior( IUnitOfWork unitOfWork, - ITenantContext tenantContext) + ITenantContext tenantContext, + ILogger> logger) : IPipelineBehavior where TRequest : notnull where TResponse : IResultBase @@ -124,7 +126,26 @@ public async Task Handle( // CancellationToken.None: the rollback is the cleanup path, and a // cancelled rollback leaves the transaction open on a connection // about to go back to the pool. - await scope.FailAsync(CancellationToken.None); + // + // Best-effort, for the same reason the commit sits outside this catch: + // the cleanup must never outrank what it is cleaning up after. A + // broken connection disposes the NpgsqlTransaction with it, so + // FailAsync throws ObjectDisposedException — measured, by terminating + // the backend mid-handler — and an unguarded await would hand the + // caller, the audit intent and the error tracker that bookkeeping + // exception with the handler's own nowhere in sight. During a database + // failover that is every in-flight request at once. Nothing is + // stranded by swallowing it: RollbackCoreAsync clears the transaction + // and the depth before it throws, and DisposeAsync still closes the + // connection in its finally. + try + { + await scope.FailAsync(CancellationToken.None); + } + catch (Exception rollbackFailure) + { + LogRollbackFailure(logger, typeof(TRequest).Name, rollbackFailure); + } // Rethrown, not swallowed: AuditLogBehavior — three behaviors out, // at step 3 — catches it, audits the failure and rethrows through @@ -133,4 +154,12 @@ public async Task Handle( throw; } } + + // LoggerMessage source-generated delegate (CA1848), matching the house style + // in AuditLogBehavior and LoggingBehavior. + private static readonly Action LogRollbackFailure = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1, nameof(LogRollbackFailure)), + "Rolling back the ambient transaction for {RequestName} failed. The original exception is rethrown; this one is recorded here only."); } diff --git a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs index e975b1b6..06f8a73b 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs @@ -21,8 +21,9 @@ namespace LearnStack.Infrastructure.Persistence; /// /// /// Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork is the guard, and it -/// reads : a context registered any other way is -/// absent from that set, which is what the rule asserts against. +/// reads off the +/// it just built: a context registered any other way is absent from that set, +/// which is what the rule asserts against. /// /// /// EF issues its own savepoints, and that is left on. A context enlisted in @@ -35,28 +36,50 @@ namespace LearnStack.Infrastructure.Persistence; /// public static class ModuleDbContextRegistration { - private static readonly HashSet Registered = []; - /// - /// Every context type registered through . + /// The context types registered through + /// on this collection. /// /// - /// Process-wide rather than per-container, because the rule that reads it is - /// a structural assertion about the composition root, and a test that built - /// its own container would otherwise assert about that container instead. + /// Per-collection, not process-wide. A static set is an assertion about the + /// whole process rather than about the container the caller just built, so + /// once anything anywhere had registered a context correctly, the rule + /// vouched for the same type registered any other way in any later container + /// — and the shape it vouched for is exactly the ADR-0040 failure: a scoped + /// ImplementationFactory building the context on its own connection + /// string, which the rule's independent lifetime leg cannot tell apart from + /// this helper's. /// - public static IReadOnlyCollection RegisteredContexts + public static IReadOnlyCollection RegisteredContexts(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + return new ReadOnlyCollection([.. Marker(services).Contexts]); + } + + /// + /// The per-collection record of what this helper registered, carried in the + /// collection itself so it travels with the container rather than the process. + /// + private sealed class RegistrationMarker + { + public HashSet Contexts { get; } = []; + } + + private static RegistrationMarker Marker(IServiceCollection services) { - get + var existing = services.FirstOrDefault( + descriptor => descriptor.ServiceType == typeof(RegistrationMarker)); + + if (existing?.ImplementationInstance is RegistrationMarker marker) { - // Inside the lock. HashSet is not safe to enumerate while another - // thread adds, and composition roots do run in parallel — the test - // host builds one per fixture. - lock (Registered) - { - return new ReadOnlyCollection([.. Registered]); - } + return marker; } + + marker = new RegistrationMarker(); + services.AddSingleton(marker); + + return marker; } /// @@ -79,10 +102,7 @@ public static IServiceCollection AddModuleDbContext(this IServiceColle return services; } - lock (Registered) - { - Registered.Add(typeof(TContext)); - } + Marker(services).Contexts.Add(typeof(TContext)); services.TryAddScoped(provider => { diff --git a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs index f0a677da..c3d7a039 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs @@ -46,6 +46,25 @@ public sealed class NpgsqlUnitOfWork(NpgsqlDataSource dataSource) : IUnitOfWork private NpgsqlConnection? _connection; private DbTransaction? _transaction; private int _depth; + + /// + /// Incremented every time a physical transaction is opened, so a frame can + /// tell "my transaction" from "a later one that happens to sit at my depth". + /// + /// + /// Depth alone is not an identity. The frame-blind CommitAsync — which + /// ADR-0040 § Amendment keeps deliberately, for a caller with no handle to + /// hand — resolves the unit without touching the handle that opened it, and + /// because the unit ended on a *commit* it is not marked rollback-only, so the + /// next BeginTransactionAsync succeeds and hands out depth 1 again. + /// The first frame is then aimed at the second transaction: measured, its + /// CompleteAsync committed the second frame's uncommitted work and + /// returned success, and its DisposeAsync rolled that work back. Every + /// route back to depth 0 through a *rollback* sets the sticky mark and is + /// therefore already shut; this is the one that is not. + /// + private int _generation; + private bool _rollbackOnly; private bool _commitRequested; private bool _disposed; @@ -96,9 +115,10 @@ public async Task BeginTransactionAsync( } _transaction = await _connection.BeginTransactionAsync(cancellationToken); + _generation++; } - return new Frame(this, ++_depth); + return new Frame(this, ++_depth, _generation); } public async Task SetTenantContextAsync( @@ -323,7 +343,8 @@ private async Task ExecuteAsync( /// opened after this one is still open would commit nothing and report /// success. /// - private sealed class Frame(NpgsqlUnitOfWork unitOfWork, int depth) : IUnitOfWorkScope + private sealed class Frame(NpgsqlUnitOfWork unitOfWork, int depth, int generation) + : IUnitOfWorkScope { private bool _resolved; @@ -331,6 +352,13 @@ private sealed class Frame(NpgsqlUnitOfWork unitOfWork, int depth) : IUnitOfWork public Task CompleteAsync(CancellationToken cancellationToken = default) { + if (IsStale()) + { + // Not an error: this frame's transaction ended, and whatever + // marked the unit afterwards belongs to a different one. + return Task.CompletedTask; + } + if (AlreadyResolved()) { if (unitOfWork._rollbackOnly) @@ -354,7 +382,7 @@ public Task CompleteAsync(CancellationToken cancellationToken = default) public Task FailAsync(CancellationToken cancellationToken = default) { - if (AlreadyResolved()) + if (IsStale() || AlreadyResolved()) { return Task.CompletedTask; } @@ -388,6 +416,9 @@ public ValueTask DisposeAsync() => private bool AlreadyResolved() => _resolved || unitOfWork._disposed || unitOfWork._depth < depth; + /// The transaction this frame belongs to is over. + private bool IsStale() => unitOfWork._generation != generation; + private void EnsureInnermost() { if (unitOfWork._depth > depth) diff --git a/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs b/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs index 78c0bfc7..1c74fb52 100644 --- a/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs +++ b/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs @@ -185,11 +185,30 @@ private void EnsureCreated() } } - // Audit metadata must always be meaningful: the default timestamp - // (0001-01-01) and the default UserId (Guid.Empty) are programmer-error - // sentinels rather than legitimate audit values. Fail loud at the call - // site rather than persisting them. - private static void EnsureValidAuditInput(DateTimeOffset at, UserId by) + private static void EnsureValidAuditInput(DateTimeOffset at, UserId by) => + AuditInput.EnsureValid(at, by); +} + +/// +/// The pair every audit stamp is made of: a meaningful instant and a real actor. +/// +/// +/// Lifted out of because the rule is the pair, +/// not the base class. An entity that carries updated_at / updated_by +/// without deriving — a composite-keyed row with no surrogate id, which cannot +/// derive — needs the same guard, and the one that skipped it accepted +/// default(DateTimeOffset) and an uninitialized actor and threw +/// ValueObjectValidationException from inside the Vogen EF converter at +/// persist time instead: three layers from the call, and naming neither the +/// property nor the aggregate. +/// +public static class AuditInput +{ + /// + /// Refuses the two programmer-error sentinels: the default timestamp + /// (0001-01-01) and the default / empty . + /// + public static void EnsureValid(DateTimeOffset at, UserId by) { if (at == default) { diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs index 23fc88c1..186a6d58 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs @@ -1,3 +1,4 @@ +using LearnStack.SharedKernel.Domain; using LearnStack.SharedKernel.Identifiers; namespace LearnStack.Modules.Tenancy.Domain; @@ -46,11 +47,9 @@ public static TenantLocale Create( { ArgumentException.ThrowIfNullOrWhiteSpace(locale); MappedLength.EnsureAtMost(locale, 35, nameof(locale)); + LocaleTag.EnsureWellFormed(locale, nameof(locale)); - if (!tenantId.IsInitialized()) - { - throw new ArgumentException("A locale belongs to a tenant.", nameof(tenantId)); - } + TenantOwned.EnsureRealTenant(tenantId, "A locale belongs to a tenant.", nameof(tenantId)); return new TenantLocale { @@ -108,10 +107,15 @@ public static TenantFeatureFlag Create( MappedLength.EnsureAtMost(key, 200, nameof(key)); JsonValue.EnsureWellFormed(value, nameof(value)); - if (!tenantId.IsInitialized()) - { - throw new ArgumentException("A feature flag belongs to a tenant.", nameof(tenantId)); - } + // The one type in this module that carries audit columns without + // deriving from AuditableEntity — a composite natural key cannot — and so + // the one that skipped its guard. Without it a sentinel timestamp and an + // uninitialized actor both persist, and the actor surfaces as + // ValueObjectValidationException out of the Vogen EF converter. + AuditInput.EnsureValid(at, by); + + TenantOwned.EnsureRealTenant( + tenantId, "A feature flag belongs to a tenant.", nameof(tenantId)); return new TenantFeatureFlag { @@ -126,6 +130,7 @@ public static TenantFeatureFlag Create( public void SetValue(string value, DateTimeOffset at, UserId by) { JsonValue.EnsureWellFormed(value, nameof(value)); + AuditInput.EnsureValid(at, by); Value = value; UpdatedAt = at; @@ -185,3 +190,87 @@ public static void EnsureWellFormed(string value, string parameterName) } } } + +/// +/// Guards a tenant-owned row's owning identifier. +/// +/// +/// IsInitialized() alone is not enough: TenantId.From(Guid.Empty) +/// reports initialized, and a nil-uuid tenant then inserts and satisfies its own +/// policy whenever app.tenant_id holds the same nil. No ADR reserves the +/// nil uuid — the platform sentinel is deliberately unfixed until Packet 9 — so +/// this is refused at the factory rather than left to collide with whatever that +/// packet chooses. +/// +internal static class TenantOwned +{ + public static void EnsureRealTenant(TenantId tenantId, string message, string parameterName) + { + if (!tenantId.IsInitialized() || tenantId.Value == Guid.Empty) + { + throw new ArgumentException(message, parameterName); + } + } +} + +/// +/// Guards a slug against the shape its column's consumers assume. +/// +/// +/// A tenant slug appears in hostnames and an organization slug is documented as a +/// DNS label, so both are lowercase alphanumeric with single interior hyphens. +/// Neither factory looked at the characters, and neither column has a CHECK — +/// platform_host_to_tenant and tenant_domains carry the host +/// normalization constraint, the slug tables do not — so a slug with a slash or +/// an uppercase letter reached a hostname unchallenged. +/// +internal static partial class UrlSlug +{ + public static void EnsureUrlSafe(string value, string parameterName) + { + if (!Pattern().IsMatch(value)) + { + throw new ArgumentException( + $"'{value}' is not a URL-safe slug: lowercase letters, digits and single " + + "interior hyphens only.", + parameterName); + } + } + + [System.Text.RegularExpressions.GeneratedRegex("^[a-z0-9]+(-[a-z0-9]+)*$")] + private static partial System.Text.RegularExpressions.Regex Pattern(); +} + +/// +/// Guards a locale tag against BCP-47 well-formedness. +/// +/// +/// 12-localization.md +/// says the column bounds the length and "well-formedness itself is validated in +/// application code, not by this column". This is that code: without it a +/// 35-character run of one letter was a locale, and the repository's own test +/// pinned that as correct. +/// +internal static partial class LocaleTag +{ + public static void EnsureWellFormed(string value, string parameterName) + { + // Shape first, then the framework. CultureInfo alone is not a check: + // .NET treats an unknown but well-formed tag as a valid custom culture, + // and on some platforms accepts tags this pattern rejects. + if (!Pattern().IsMatch(value)) + { + throw new ArgumentException( + $"'{value}' is not a well-formed BCP-47 language tag — expected forms are " + + "'tr', 'tr-TR', 'zh-Hans', 'zh-Hans-CN'.", + parameterName); + } + } + + // language[-script][-region][-variant…]: 2-3 letter (or 4-8 for registered + // subtags) primary, optional 4-letter script, optional 2-letter or 3-digit + // region, then variant subtags. + [System.Text.RegularExpressions.GeneratedRegex( + "^[a-zA-Z]{2,8}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-([a-zA-Z0-9]{5,8}|[0-9][a-zA-Z0-9]{3}))*$")] + private static partial System.Text.RegularExpressions.Regex Pattern(); +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs index 882ec387..e93c1999 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs @@ -116,20 +116,19 @@ public static Organization Create( ArgumentException.ThrowIfNullOrWhiteSpace(slug); ArgumentException.ThrowIfNullOrWhiteSpace(displayName); EnsureWithinMappedLengths(slug, displayName); + UrlSlug.EnsureUrlSafe(slug, nameof(slug)); - if (!id.IsInitialized()) + if (!id.IsInitialized() || id.Value == Guid.Empty) { throw new ArgumentException( "The identifier was never assigned; construct it through its factory.", nameof(id)); } - if (!tenantId.IsInitialized()) - { - throw new ArgumentException( - "An organization belongs to a tenant; the tenant id was never assigned.", - nameof(tenantId)); - } + TenantOwned.EnsureRealTenant( + tenantId, + "An organization belongs to a tenant; the tenant id was never assigned.", + nameof(tenantId)); var organization = new Organization(id) { @@ -165,11 +164,38 @@ private static void EnsureWithinMappedLengths(string slug, string displayName) } /// Moves the organization to a new lifecycle state. + /// See Tenant.ChangeStatus: same argument, one fewer state. public void ChangeStatus(OrganizationStatus status, IClock clock, UserId updatedBy) { ArgumentNullException.ThrowIfNull(clock); + EnsureTransitionAllowed(status); Status = status; MarkUpdated(clock.UtcNow, updatedBy); } + + private void EnsureTransitionAllowed(OrganizationStatus target) + { + if (!Enum.IsDefined(target)) + { + throw new ArgumentOutOfRangeException( + nameof(target), target, "Not a defined OrganizationStatus."); + } + + var allowed = Status switch + { + OrganizationStatus.Active => target is OrganizationStatus.Suspended + or OrganizationStatus.Archived, + OrganizationStatus.Suspended => target is OrganizationStatus.Active + or OrganizationStatus.Archived, + _ => false, + }; + + if (!allowed && target != Status) + { + throw new InvalidOperationException( + $"An organization cannot move from {Status} to {target}. " + + "Active and Suspended swap and both archive; Archived is terminal."); + } + } } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs index 2fd06a59..f2fcedd9 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs @@ -93,13 +93,14 @@ public static Tenant Create( ArgumentNullException.ThrowIfNull(clock); ArgumentException.ThrowIfNullOrWhiteSpace(slug); ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + MappedLength.EnsureAtMost(slug, 63, nameof(slug)); + MappedLength.EnsureAtMost(displayName, 200, nameof(displayName)); + UrlSlug.EnsureUrlSafe(slug, nameof(slug)); - if (!id.IsInitialized()) - { - throw new ArgumentException( - "A tenant id is assigned by the registry that owns the tenant, never minted here.", - nameof(id)); - } + TenantOwned.EnsureRealTenant( + id, + "A tenant id is assigned by the registry that owns the tenant, never minted here.", + nameof(id)); var tenant = new Tenant(id) { @@ -125,7 +126,7 @@ public void AssignDefaultOrganization(OrganizationId organizationId, IClock cloc { ArgumentNullException.ThrowIfNull(clock); - if (!organizationId.IsInitialized()) + if (!organizationId.IsInitialized() || organizationId.Value == Guid.Empty) { throw new ArgumentException( "The default organization must be a real organization.", nameof(organizationId)); @@ -136,11 +137,49 @@ public void AssignDefaultOrganization(OrganizationId organizationId, IClock cloc } /// Moves the tenant to a new lifecycle state. + /// + /// The transitions the module spec's state diagram draws, and no others. + /// A bare assignment took Archived → Active, Active → Trial and + /// (TenantStatus)999; the column's CHECK stops only the third, because + /// it can see the value and not where the row came from. This is the same + /// argument TenantDomain.EnsureVerifiable makes for the sibling + /// aggregate — "the schema would not object, so the aggregate is where the + /// invariant lives". + /// public void ChangeStatus(TenantStatus status, IClock clock, UserId updatedBy) { ArgumentNullException.ThrowIfNull(clock); + EnsureTransitionAllowed(status); Status = status; MarkUpdated(clock.UtcNow, updatedBy); } + + private void EnsureTransitionAllowed(TenantStatus target) + { + if (!Enum.IsDefined(target)) + { + throw new ArgumentOutOfRangeException( + nameof(target), target, "Not a defined TenantStatus."); + } + + // Archived is terminal for serving: a tenant is retained for audit and + // retention obligations and never served again, so nothing leaves it. + var allowed = Status switch + { + TenantStatus.Trial => target is TenantStatus.Active or TenantStatus.Suspended + or TenantStatus.Archived, + TenantStatus.Active => target is TenantStatus.Suspended or TenantStatus.Archived, + TenantStatus.Suspended => target is TenantStatus.Active or TenantStatus.Archived, + _ => false, + }; + + if (!allowed && target != Status) + { + throw new InvalidOperationException( + $"A tenant cannot move from {Status} to {target}. " + + "Trial goes to Active, Suspended or Archived; Active and Suspended swap and " + + "both archive; Archived is terminal."); + } + } } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs index 3a775df9..3baff241 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs @@ -85,11 +85,39 @@ public static TenantDomain RequestCustomDomain( TenantDomainId id, TenantId tenantId, string host, IClock clock, UserId createdBy) => CreateCore(id, tenantId, host, TenantDomainKind.Custom, clock, createdBy); + /// + /// Starts a verification attempt, moving the domain into + /// . + /// + /// + /// The state the enum defined, the CHECK accepted, the module spec drew — and + /// no code could produce. Without it a custom domain went + /// Requested → Verified in one call, a transition no diagram in the + /// corpus draws, and Verifying was an unreachable value in a closed + /// set. Who calls it is + /// Phase + /// 02c's custom-domain lifecycle; the state machine is this aggregate's + /// either way. + /// + public void MarkVerificationStarted(IClock clock, UserId updatedBy) + { + ArgumentNullException.ThrowIfNull(clock); + EnsureVerifiable(); + EnsureStatusIs( + "start verification", + TenantDomainStatus.Requested, + TenantDomainStatus.Failed); + + Status = TenantDomainStatus.Verifying; + MarkUpdated(clock.UtcNow, updatedBy); + } + /// Records a verification attempt that succeeded. public void MarkVerified(IClock clock, UserId updatedBy) { ArgumentNullException.ThrowIfNull(clock); EnsureVerifiable(); + EnsureStatusIs("record a verification result", TenantDomainStatus.Verifying); Status = TenantDomainStatus.Verified; VerifiedAt = clock.UtcNow; @@ -103,7 +131,12 @@ public void MarkVerificationFailed(string error, IClock clock, UserId updatedBy) { ArgumentNullException.ThrowIfNull(clock); ArgumentException.ThrowIfNullOrWhiteSpace(error); + // The column holds 1000; an unbounded provider message otherwise arrives + // as 22001 from three layers down, naming neither the property nor the + // aggregate. + MappedLength.EnsureAtMost(error, 1000, nameof(error)); EnsureVerifiable(); + EnsureStatusIs("record a verification result", TenantDomainStatus.Verifying); Status = TenantDomainStatus.Failed; VerificationAttempts++; @@ -133,6 +166,20 @@ private void EnsureVerifiable() } } + /// + /// Refuses a transition the module spec's state diagram does not draw. + /// + private void EnsureStatusIs(string operation, params TenantDomainStatus[] allowed) + { + if (!allowed.Contains(Status)) + { + throw new InvalidOperationException( + $"A domain in {Status} cannot {operation}; expected " + + $"{string.Join(" or ", allowed)}. The lifecycle is " + + "Requested → Verifying → Verified | Failed, and Failed may start over."); + } + } + private static TenantDomain CreateCore( TenantDomainId id, TenantId tenantId, @@ -144,17 +191,15 @@ private static TenantDomain CreateCore( ArgumentNullException.ThrowIfNull(clock); ArgumentException.ThrowIfNullOrWhiteSpace(host); - if (!id.IsInitialized()) + if (!id.IsInitialized() || id.Value == Guid.Empty) { throw new ArgumentException( "The identifier was never assigned; construct it through its factory.", nameof(id)); } - if (!tenantId.IsInitialized()) - { - throw new ArgumentException("A domain belongs to a tenant.", nameof(tenantId)); - } + TenantOwned.EnsureRealTenant( + tenantId, "A domain belongs to a tenant.", nameof(tenantId)); // The database carries the same rule as ck_tenant_domains_host_normalized; // this is the loud half, so a caller that skipped EffectiveHost.Normalize diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs index 414fe861..aa2c46ba 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs @@ -70,18 +70,31 @@ public static TenantSetting Create( MappedLength.EnsureAtMost(key, 200, nameof(key)); JsonValue.EnsureWellFormed(value, nameof(value)); - if (!id.IsInitialized()) + if (!id.IsInitialized() || id.Value == Guid.Empty) { throw new ArgumentException( "The identifier was never assigned; construct it through its factory.", nameof(id)); } - if (!tenantId.IsInitialized()) + if (!tenantId.IsInitialized() || tenantId.Value == Guid.Empty) { throw new ArgumentException("A setting belongs to a tenant.", nameof(tenantId)); } + // The nullable says "tenant-wide or organization-scoped". It does not say + // "an uninitialized id is fine": an unset Vogen wrapper inside the + // nullable persisted as far as the EF converter and surfaced there as + // ValueObjectValidationException, three layers from this call. + if (organizationId is { } organization + && (!organization.IsInitialized() || organization.Value == Guid.Empty)) + { + throw new ArgumentException( + "An organization-scoped setting names a real organization; pass null for a " + + "tenant-wide one.", + nameof(organizationId)); + } + var setting = new TenantSetting(id) { TenantId = tenantId, diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index 221da2de..07dbad32 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -116,10 +116,14 @@ public void Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork() // The registration half: the composition root's own persistence // registration is run, and every DbContext service in it must be one the // shared helper registered. A context registered any other way is absent - // from ModuleDbContextRegistration.RegisteredContexts. + // from this collection's marker — read off the collection, so the answer + // is about the container built here and not about anything another test + // in this process happened to register first. var services = new ServiceCollection(); services.AddLearnStackPersistence(new ConfigurationBuilder().Build()); + var registered = services.RegisteredContexts(); + var contexts = services .Where(descriptor => typeof(DbContext).IsAssignableFrom(descriptor.ServiceType)) .ToList(); @@ -127,7 +131,7 @@ public void Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork() contexts.Should().NotBeEmpty("TenancyDbContext is registered and is the first consumer"); contexts.Should().OnlyContain( - descriptor => ModuleDbContextRegistration.RegisteredContexts.Contains(descriptor.ServiceType), + descriptor => registered.Contains(descriptor.ServiceType), "every DbContext registration goes through AddModuleDbContext"); contexts.Should().OnlyContain( @@ -229,14 +233,49 @@ public void TransactionBehavior_Does_Not_Reference_A_Module_Assembly() /// Source with its comments removed. /// /// + /// /// Every file the scan above touches argues in prose about the very call it /// is forbidden to make, so scanning raw text would fail on the documentation /// that explains the rule. + /// + /// + /// Through the literal-aware , not a + /// regex. A regex has no literal state: the // inside + /// "https://…" opens a line comment and deletes the rest of that line + /// before the scan reads it, and a /* inside a string blanks an + /// arbitrary multi-line region up to the next */ anywhere in the + /// file. This is the guard for the ADR-0040 seam; a hole in it is a hole in + /// that. + /// /// - private static string StripComments(string source) => - System.Text.RegularExpressions.Regex.Replace( - source, @"//[^\n]*|/\*.*?\*/", string.Empty, - System.Text.RegularExpressions.RegexOptions.Singleline); + private static string StripComments(string source) => SourceText.WithoutComments(source); + + [Fact] + public void The_registration_marker_does_not_vouch_across_containers() + { + // What a process-wide marker set got wrong. The rule's other leg — + // Scoped + ImplementationFactory — cannot tell this helper's registration + // from a hand-rolled scoped factory that builds the context on its own + // connection string, which is precisely the ADR-0040 failure. So for that + // one shape the marker is the whole guard, and a marker that answers for + // the process rather than the container answers about a registration that + // is not the one in front of it. + var correct = new ServiceCollection(); + correct.AddModuleDbContext(); + correct.RegisteredContexts().Should().Contain(typeof(ProbeDbContext)); + + var foreign = new ServiceCollection(); + foreign.AddScoped(_ => new ProbeDbContext( + new DbContextOptionsBuilder().Options)); + + foreign.RegisteredContexts().Should().BeEmpty( + "the marker answers for the collection it was read from, not for " + + "whatever any other container in this process registered first"); + } + + /// A context type that exists only to be registered two ways. + private sealed class ProbeDbContext(DbContextOptions options) + : DbContext(options); [Fact] public void Every_Database_Test_Carries_The_Docker_Trait() @@ -316,6 +355,78 @@ public void Migrate_Target_Covers_Every_Migration_Chain() + "unmigrated on the only path Standards 05 § Database roles documents"); } + [Theory] + // Npgsql parses every one of these into Username / Password — measured + // against Npgsql 10, not read off a page. The recipe recognised the first + // pair only, so the other three read the role as empty and printed the + // password unredacted. + [InlineData("Username", "Password")] + [InlineData("UID", "PWD")] + [InlineData("User ID", "PSW")] + [InlineData("USERID", "Pwd")] + public void Migrate_Target_Refuses_An_Aliased_Runtime_Credential(string userKey, string secretKey) + { + // Executed, not scanned. A test that asserted the recipe's text contained + // "uid" would pass against a recipe that matched the alias and then did + // nothing with it — and the defect being fixed here is precisely a keyword + // table that existed and was incomplete. + var value = $"Host=localhost;Database=learnstack;{userKey}=learnstack_app;{secretKey}=hunter2"; // leakwatch:ignore + + var (exitCode, output) = RunMigrateTarget(value); + + exitCode.Should().NotBe(0, "learnstack_app does not own the tables: {0}", output); + output.Should().Contain("learnstack_app", "the operator has to be told which role they named"); + output.Should().NotContain( + "hunter2", + "the one target whose purpose is keeping the migration credential in one " + + "place must not echo it into a terminal or a CI log"); + } + + [Fact] + public void Migrate_Target_Refuses_A_Uri_Without_Echoing_Its_Userinfo() + { + // The form DATABASE_URL carries on several hosts, and the form that has no + // `password=` in it for a keyword pass to find. + var (exitCode, output) = RunMigrateTarget( + "postgres://learnstack_app:hunter2@localhost:5432/learnstack"); // leakwatch:ignore + + exitCode.Should().NotBe(0, "{0}", output); + output.Should().NotContain("hunter2"); + output.Should().Contain("key/value", "the message names the form that would work"); + } + + /// + /// Runs the repo-root migrate target with a given migration credential + /// and returns its exit code and combined output. + /// + /// + /// Every value the callers pass is refused by the role check, which runs before + /// the recipe restores a tool or opens a socket — so this touches no database + /// and needs no Docker. + /// + private static (int ExitCode, string Output) RunMigrateTarget(string migrationConnectionString) + { + var startInfo = new System.Diagnostics.ProcessStartInfo("make") + { + WorkingDirectory = RepositoryPaths.RepoRoot(), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + startInfo.ArgumentList.Add("migrate"); + startInfo.Environment["ConnectionStrings__Migration"] = migrationConnectionString; + + using var process = System.Diagnostics.Process.Start(startInfo) + ?? throw new InvalidOperationException("`make` did not start."); + + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(milliseconds: 60_000).Should().BeTrue("the role check exits immediately"); + + return (process.ExitCode, stdout + stderr); + } + /// /// The body of the repo-root Makefile's migrate target. /// diff --git a/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs index 0c882b14..32dcc347 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs @@ -295,6 +295,80 @@ public async Task TheRolesScriptTakesBackAnEscalatedAttribute() } } + [Theory] + // Directly, and through a bridge role that holds the membership on the + // application role's behalf — because a fix keyed on the four names would + // catch the first and not the second. + [InlineData(false)] + [InlineData(true)] + public async Task TheRolesScriptRevokesAnEscalatedMembership(bool throughABridge) + { + // Converging the ATTRIBUTES was only half of it. `GRANT + // learnstack_platform TO learnstack_app` leaves learnstack_app's own + // rolbypassrls false — so every attribute the script re-asserts still + // reads correctly — and lets it `SET ROLE learnstack_platform`, which + // makes every policy in the database one statement away from inert. + var bridge = $"bridge_{(throughABridge ? "indirect" : "direct")}"; + + try + { + if (throughABridge) + { + await _postgres.ExecuteAsSuperuserAsync( + $"CREATE ROLE {bridge} NOLOGIN; " + + $"GRANT learnstack_platform TO {bridge}; " + + $"GRANT {bridge} TO learnstack_app"); + } + else + { + await _postgres.ExecuteAsSuperuserAsync("GRANT learnstack_platform TO learnstack_app"); + } + + (await ReachesABypassRoleAsync()).Should().BeTrue( + "the escalation must be real for the test to mean anything"); + + var result = await _postgres.RunRolesScriptAgainAsync(); + result.ExitCode.Should().Be(0, "{0}", result.Stderr); + + (await ReachesABypassRoleAsync()).Should().BeFalse( + "re-running the script revokes every membership the four roles hold"); + } + finally + { + await _postgres.ExecuteAsSuperuserAsync( + "REVOKE learnstack_platform FROM learnstack_app"); + + if (throughABridge) + { + await _postgres.ExecuteAsSuperuserAsync( + $"DROP ROLE IF EXISTS {bridge}"); + } + } + } + + /// + /// Whether learnstack_app can reach any role that bypasses row + /// security — its own attributes included, and any chain of memberships. + /// + /// + /// The same predicate the composition root asks of every physical connection. + /// pg_has_role(..., 'MEMBER') follows the whole chain, which is what a + /// check on the role's own rolbypassrls cannot do. + /// + private async Task ReachesABypassRoleAsync() + { + await using var connection = await PostgresFixture.OpenAsync(_postgres.AppConnectionString); + await using var command = new NpgsqlCommand( + """ + SELECT EXISTS ( + SELECT 1 FROM pg_roles r + WHERE (r.rolbypassrls OR r.rolsuper) + AND pg_has_role(current_user, r.oid, 'MEMBER')) + """, (NpgsqlConnection)connection); + + return (bool)(await command.ExecuteScalarAsync())!; + } + private async Task ReadIsSuperuserAsync() { await using var connection = await PostgresFixture.OpenAsync(_postgres.MigrationConnectionString); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs index 0739bca6..db3e2ce7 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs @@ -169,32 +169,146 @@ public async Task TenantWide_Row_Of_TenantB_Is_Invisible_To_TenantA() "tenant B's tenant-wide setting must not be visible to tenant A"); } + /// + /// One foreign-tenant write per table whose policy carries a + /// WITH CHECK, keyed by table name. + /// + /// + /// Hand-written because each table needs its own column list, and audited + /// against the database by + /// — which reads + /// `pg_policies` and fails when a table has a `WITH CHECK` and no case here. + /// The version this replaced named three tables and claimed in a comment to + /// cover "every table whose policy carries a WITH CHECK". It covered three of + /// nine: `WITH CHECK (true)` on `tenant_locales` passed the entire suite. + /// + public static readonly Dictionary ForeignTenantWrites = new(StringComparer.Ordinal) + { + // Self-keyed: the tenant term is on `id`, so a new id is already foreign. + ["tenants"] = + """ + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES (uuidv7(), 'sneak', 'Sneak', 'Active', now(), @actor, 0) + """, + ["organizations"] = + """ + INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) + VALUES (uuidv7(), @foreign, 'sneak', 'Sneak', 'Active', now(), @actor, 0) + """, + ["tenant_domains"] = + """ + INSERT INTO tenant_domains (id, tenant_id, host, kind, status, created_at, created_by, row_version) + VALUES (uuidv7(), @foreign, 'sneak.example.test', 'Custom', 'Requested', now(), @actor, 0) + """, + ["tenant_locales"] = + """ + INSERT INTO tenant_locales (tenant_id, locale, is_default) + VALUES (@foreign, 'zz-Sneak', false) + """, + ["tenant_feature_flags"] = + """ + INSERT INTO tenant_feature_flags (tenant_id, key, value, updated_by) + VALUES (@foreign, 'sneak', '{}', @actor) + """, + ["tenant_settings"] = + """ + INSERT INTO tenant_settings (id, tenant_id, key, value, created_at, created_by, row_version) + VALUES (uuidv7(), @foreign, 'sneak', '{}', now(), @actor, 0) + """, + ["platform_entitlement_cache"] = + """ + INSERT INTO platform_entitlement_cache + (tenant_id, plan_code, features, limits, compliance, valid_until, source) + VALUES (@foreign, 'sneak', '{}', '{}', '{}', now() + interval '1 day', 'null-provider') + """, + // Platform-scoped: the read is widened by app.resolving_host, the write is + // not. This is the statement that proves the widening did not leak. + ["platform_host_to_tenant"] = + """ + INSERT INTO platform_host_to_tenant (host, tenant_id, is_active, is_publicly_live) + VALUES ('sneak.example.test', @foreign, true, true) + """, + // Where the consequence is loudest: an event enqueued into another + // tenant's stream is delivered under that tenant's context by the + // dispatcher. + ["outbox_messages"] = + """ + INSERT INTO outbox_messages (tenant_id, correlation_id, type, topic, partition_key, payload) + VALUES (@foreign, '00-sneak-span-01', 'Sneak', 'learnstack.tenancy.tenant', 'k', '{}') + """, + ["idempotency_keys"] = + """ + INSERT INTO idempotency_keys (tenant_id, key, fingerprint, claim_token, state, expires_at) + VALUES (@foreign, 'sneak-key-01', 'fp', uuidv7(), 'in_flight', now() + interval '5 minutes') + """, + }; + + public static TheoryData TablesWithAWithCheck() + { + var data = new TheoryData(); + + foreach (var table in ForeignTenantWrites.Keys) + { + data.Add(table); + } + + return data; + } + + [Theory] + [MemberData(nameof(TablesWithAWithCheck))] + public async Task Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck(string table) + { + await ExpectForeignWriteRefusedAsync(ForeignTenantWrites[table]); + } + [Theory] - [InlineData( - """ - INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) - VALUES (uuidv7(), @foreign, 'sneak', 'Sneak', 'Active', now(), @actor, 0) - """)] + // The other half of WITH CHECK: not inserting a foreign row, but handing an + // owned one away. USING admits the row; WITH CHECK refuses the new value. [InlineData("UPDATE organizations SET tenant_id = @foreign WHERE tenant_id <> @foreign")] - [InlineData( - """ - INSERT INTO outbox_messages (tenant_id, correlation_id, type, topic, partition_key, payload) - VALUES (@foreign, '00-sneak-span-01', 'Sneak', 'learnstack.tenancy.tenant', 'k', '{}') - """)] - [InlineData( - """ - INSERT INTO idempotency_keys (tenant_id, key, fingerprint, claim_token, state, expires_at) - VALUES (@foreign, 'sneak-key-01', 'fp', uuidv7(), 'in_flight', now() + interval '5 minutes') - """)] - public async Task Write_With_Foreign_TenantId_Is_Rejected_By_WithCheck(string statement) + [InlineData("UPDATE tenant_settings SET tenant_id = @foreign WHERE tenant_id <> @foreign")] + public async Task Reassigning_An_Owned_Row_To_Another_Tenant_Is_Rejected(string statement) + { + await ExpectForeignWriteRefusedAsync(statement); + } + + [Fact] + public async Task Every_WithCheck_Policy_Has_A_Foreign_Write_Case() + { + // The catalogue is the authority, not this file. A table that gains a + // WITH CHECK and no case above fails here rather than passing silently for + // as long as nobody notices — which is exactly how six of the ten came to + // be unexercised. + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.PlatformConnectionString); + await using var command = new NpgsqlCommand( + """ + SELECT DISTINCT tablename + FROM pg_policies + WHERE schemaname = 'public' AND with_check IS NOT NULL + """, (NpgsqlConnection)connection); + + var guarded = new List(); + await using (var reader = await command.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + guarded.Add(reader.GetString(0)); + } + } + + guarded.Should().NotBeEmpty("the sweep must be reading the applied schema, not an empty result set"); + guarded.Should().BeEquivalentTo( + ForeignTenantWrites.Keys, + "every table whose policy constrains a write has a write that proves it, " + + "and every case here names a table that still has one"); + } + + /// + /// Runs a statement as learnstack_app under tenant B's context, with + /// tenant A as @foreign, and asserts the policy refuses it. + /// + private async Task ExpectForeignWriteRefusedAsync(string statement) { - // Every table whose policy carries a WITH CHECK, including the two the - // platform chain owns. `outbox_messages` is where the consequence is - // loudest: a handler that could enqueue into another tenant's stream would - // have its event delivered under that tenant's context by the dispatcher. - // The UPDATE case runs under tenant B's context against tenant B's own row - // and tries to hand it to tenant A — USING admits the row, WITH CHECK - // refuses the new value. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); await using var transaction = await connection.BeginTransactionAsync(); await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantB); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index 54986a68..7a4f4e74 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -10,6 +10,7 @@ using LearnStack.SharedKernel.Tenancy; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; using Npgsql; using Xunit; @@ -209,9 +210,13 @@ public async Task An_absorbed_inner_failure_still_commits_the_outer_work() { var unitOfWork = scope.ServiceProvider.GetRequiredService(); var outer = new TransactionBehavior>( - unitOfWork, Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + unitOfWork, + Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1), + NullLogger>>.Instance); var inner = new TransactionBehavior>( - unitOfWork, Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + unitOfWork, + Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1), + NullLogger>>.Instance); var result = await outer.Handle( new Probe(), @@ -400,6 +405,40 @@ public async Task The_data_source_refuses_a_runtime_role_that_was_granted_bypass healthy.State.Should().Be(System.Data.ConnectionState.Open); } + [Fact] + public async Task The_data_source_refuses_a_runtime_role_that_can_reach_one() + { + // The escalation a check on the role's own attributes cannot see: + // `GRANT learnstack_platform TO learnstack_app` leaves learnstack_app's + // rolbypassrls false and lets it SET ROLE into a BYPASSRLS role. Through a + // bridge role, because a guard keyed on the four names would catch the + // direct grant and miss this one. + try + { + await _schema.Postgres.ExecuteAsSuperuserAsync( + "CREATE ROLE uow_bridge NOLOGIN; " + + "GRANT learnstack_platform TO uow_bridge; " + + "GRANT uow_bridge TO learnstack_app"); + + await using var dataSource = PersistenceCompositionExtensions.BuildApplicationDataSource( + _schema.Postgres.AppConnectionString); + + var open = async () => + { + await using var connection = await dataSource.OpenConnectionAsync(); + }; + + (await open.Should().ThrowAsync()) + .WithMessage("*reach one which bypasses Row Level Security*"); + } + finally + { + await _schema.Postgres.ExecuteAsSuperuserAsync( + "REVOKE uow_bridge FROM learnstack_app"); + await _schema.Postgres.ExecuteAsSuperuserAsync("DROP ROLE IF EXISTS uow_bridge"); + } + } + [Fact] public async Task A_nested_failure_makes_the_outer_commit_impossible() { @@ -505,6 +544,68 @@ INSERT INTO outbox_messages } } + [Theory] + // Both terminal calls on the stale handle. Measured before the fix: + // CompleteAsync committed the SECOND frame's uncommitted work and returned + // success; DisposeAsync rolled it back and then made the second frame's own + // CompleteAsync throw "already resolved by a rollback". + [InlineData(true)] + [InlineData(false)] + public async Task A_frame_left_over_from_a_committed_unit_does_not_touch_the_next_one( + bool completeTheStaleFrame) + { + // The one door back to depth 1 with a live handle: the frame-blind + // CommitAsync that ADR-0040 § Amendment keeps for a caller with no handle + // to hand. Every route back through a ROLLBACK sets the sticky mark and + // BeginTransactionAsync refuses — measured — so this is the only one. + await using var provider = BuildProvider(); + var first = $"00-uow-{Guid.CreateVersion7():N}"; + var second = $"00-uow-{Guid.CreateVersion7():N}"; + + try + { + await using (var scope = provider.CreateAsyncScope()) + { + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + var stale = await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync( + Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + await InsertOutboxAsync(unitOfWork, first); + await unitOfWork.CommitAsync(); + + var current = await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync( + Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + await InsertOutboxAsync(unitOfWork, second); + + if (completeTheStaleFrame) + { + await stale.CompleteAsync(); + } + else + { + await stale.DisposeAsync(); + } + + unitOfWork.HasActiveTransaction.Should().BeTrue( + "the stale frame belongs to a transaction that is already over"); + + await current.CompleteAsync(); + } + + (await CountOutboxAsync(first)).Should().Be(1L, "the first unit committed"); + (await CountOutboxAsync(second)).Should().Be(1L, + "the second unit committed on its own frame, and the stale one neither " + + "committed it early nor rolled it back"); + } + finally + { + await DeleteOutboxAsync(first); + await DeleteOutboxAsync(second); + } + } + // ── Helpers ────────────────────────────────────────────────────────────── private ServiceProvider BuildProvider() @@ -532,6 +633,26 @@ private async Task CountOrganizationsAsync(string slug) return (long)(await command.ExecuteScalarAsync())!; } + private static Task InsertOutboxAsync(IUnitOfWork unitOfWork, string correlation) => + ExecuteAsync(unitOfWork, + """ + INSERT INTO outbox_messages + (tenant_id, correlation_id, type, topic, partition_key, payload) + VALUES (@tenant, @correlation, 'T', 'learnstack.tenancy.tenant', 'k', '{}') + """, + ("tenant", SchemaFixture.TenantA), ("correlation", correlation)); + + private async Task DeleteOutboxAsync(string correlation) + { + await using var platform = await PostgresFixture.OpenAsync( + _schema.Postgres.PlatformConnectionString); + await using var cleanup = new NpgsqlCommand( + "DELETE FROM outbox_messages WHERE correlation_id = @correlation", + (NpgsqlConnection)platform); + cleanup.Parameters.AddWithValue("correlation", correlation); + await cleanup.ExecuteNonQueryAsync(); + } + private async Task CountOutboxAsync(string correlation) { await using var platform = await PostgresFixture.OpenAsync( diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs index 73111f94..69a8115f 100644 --- a/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs @@ -104,6 +104,12 @@ public void No_message_carries_the_password() "Host=localhost;Username=learnstack_platform;PSW=hunter2", // leakwatch:ignore "Host=localhost;Port=nope;Username=learnstack_app;Password=hunter2", // leakwatch:ignore "Host=localhost;Port=nope;Username=learnstack_app;Pwd=hunter2", // leakwatch:ignore + // The URI form carries its password in the userinfo, where no + // `password=` appears for a keyword regex to find — and Npgsql rejects + // the form outright, so this branch is exactly where it lands. The + // first version of the redaction echoed it whole. + "postgres://learnstack_app:hunter2@localhost:5432/learnstack", // leakwatch:ignore + "postgresql://learnstack_app:hunter2@localhost/learnstack?sslmode=require", // leakwatch:ignore }) { try @@ -116,7 +122,7 @@ public void No_message_carries_the_password() } } - messages.Should().HaveCount(5, "every value is refused"); + messages.Should().HaveCount(7, "every value is refused"); messages.Should().OnlyContain(message => !message.Contains("hunter2", StringComparison.Ordinal)); messages.Should().OnlyContain(message => message.Contains("***", StringComparison.Ordinal)); } diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs index d134b6a4..fd60a98e 100644 --- a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs @@ -6,6 +6,7 @@ using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using MediatR; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace LearnStack.Tests.Unit.Application.Pipeline; @@ -223,8 +224,55 @@ public async Task Passes_The_Ambient_Tenant_Context_Through() unitOfWork.TenantContext.Should().BeSameAs(UnresolvedTenantContext.Instance); } + [Fact] + public async Task A_failing_rollback_does_not_replace_the_exception_it_is_cleaning_up_after() + { + // The measured shape: an exception breaks the connection, Npgsql disposes + // the NpgsqlTransaction with it, and the rollback then throws + // ObjectDisposedException. Unguarded, that is what the caller, + // AuditLogBehavior and IErrorTrackingProvider all see — the handler's own + // exception simply gone, not even as an inner. + var unitOfWork = new RecordingUnitOfWork + { + RollbackFailure = new ObjectDisposedException("NpgsqlTransaction"), + }; + + var behavior = Build(unitOfWork); + var original = new DbTestException("the handler's own"); + + var act = async () => await behavior.Handle( + new DummyCommand(), + () => throw original, + default); + + (await act.Should().ThrowAsync()).Which.Should().BeSameAs(original); + unitOfWork.Calls.Should().Contain("rollback", "the cleanup was still attempted"); + unitOfWork.Committed.Should().BeFalse(); + } + + [Fact] + public async Task A_failing_rollback_does_not_turn_a_cancellation_into_a_failure() + { + // Three ADR-0032 behaviours hang off the exception TYPE: AuditLogBehavior + // does not audit an OperationCanceledException, LearnStackExceptionHandler + // does not capture one, and HttpStatusMap answers 499 rather than 500. A + // rollback that replaced it inverted all three at once. + var unitOfWork = new RecordingUnitOfWork + { + RollbackFailure = new ObjectDisposedException("NpgsqlTransaction"), + }; + + var act = async () => await Build(unitOfWork).Handle( + new DummyCommand(), + () => throw new OperationCanceledException("Query was cancelled"), + default); + + await act.Should().ThrowAsync(); + } + private static TransactionBehavior> Build(IUnitOfWork unitOfWork) => - new(unitOfWork, UnresolvedTenantContext.Instance); + new(unitOfWork, UnresolvedTenantContext.Instance, + NullLogger>>.Instance); private static Task> Next(RecordingUnitOfWork unitOfWork, Result result) { @@ -252,6 +300,12 @@ private sealed class RecordingUnitOfWork : IUnitOfWork /// Thrown by the outermost commit, to model a faulted COMMIT. public Exception? CommitFailure { get; init; } + /// + /// Thrown by the rollback, to model the cleanup path failing on a + /// connection the original exception already broke. + /// + public Exception? RollbackFailure { get; init; } + public bool Committed { get; private set; } public DbConnection Connection => @@ -315,7 +369,10 @@ public Task RollbackAsync(CancellationToken cancellationToken = default) Calls.Add("rollback"); --_depth; - return Task.CompletedTask; + + return RollbackFailure is null + ? Task.CompletedTask + : Task.FromException(RollbackFailure); } public void MarkRollbackOnly() diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs index 6bfea2a3..f37b0c07 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using LearnStack.Modules.Tenancy.Domain; +using TenancyDomain = LearnStack.Modules.Tenancy.Domain; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Time; using Xunit; @@ -78,18 +79,53 @@ public void A_custom_domain_records_its_verification_attempts() var domain = TenantDomain.RequestCustomDomain( DomainId, Tenant, "learn.acme.com", Clock, Actor); + domain.MarkVerificationStarted(Clock, Actor); + domain.Status.Should().Be(TenantDomainStatus.Verifying); + domain.MarkVerificationFailed("no TXT record", Clock, Actor); domain.Status.Should().Be(TenantDomainStatus.Failed); domain.VerificationAttempts.Should().Be(1); domain.LastVerificationError.Should().Be("no TXT record"); + // Failed may start over, which is the edge the module spec's diagram + // draws back into Verifying. + domain.MarkVerificationStarted(Clock, Actor); domain.MarkVerified(Clock, Actor); domain.Status.Should().Be(TenantDomainStatus.Verified); domain.VerificationAttempts.Should().Be(2); domain.LastVerificationError.Should().BeNull("a success clears the previous failure"); - domain.Version.Should().Be(2, "each attempt is an audited, versioned mutation"); + domain.Version.Should().Be(4, "each transition is an audited, versioned mutation"); + } + + [Fact] + public void A_verification_result_needs_a_verification_in_progress() + { + // Requested → Verified in one call is a transition no diagram in the + // corpus draws, and the status CHECK cannot see where a row came from. + var domain = TenantDomain.RequestCustomDomain( + DomainId, Tenant, "learn.acme.com", Clock, Actor); + + var verify = () => domain.MarkVerified(Clock, Actor); + var fail = () => domain.MarkVerificationFailed("no TXT record", Clock, Actor); + + verify.Should().Throw().WithMessage("*Requested*Verifying*"); + fail.Should().Throw().WithMessage("*Requested*Verifying*"); + domain.Status.Should().Be(TenantDomainStatus.Requested); + } + + [Fact] + public void A_verified_domain_does_not_start_verifying_again() + { + var domain = TenantDomain.RequestCustomDomain( + DomainId, Tenant, "learn.acme.com", Clock, Actor); + domain.MarkVerificationStarted(Clock, Actor); + domain.MarkVerified(Clock, Actor); + + var restart = () => domain.MarkVerificationStarted(Clock, Actor); + + restart.Should().Throw(); } [Theory] @@ -226,14 +262,164 @@ public void An_organization_is_bounded_by_the_lengths_its_columns_hold( } } + [Theory] + // The module spec's state diagram, as a table. A bare assignment took every + // pair, including Archived → Active and (TenantStatus)999; ck_tenants_status + // stops only the third, because a CHECK sees the value and not where the row + // came from. + [InlineData(TenantStatus.Active, true)] + [InlineData(TenantStatus.Suspended, true)] + [InlineData(TenantStatus.Archived, true)] + public void A_trial_tenant_moves_where_the_diagram_draws(TenantStatus target, bool allowed) + { + var tenant = NewTenant(); + + var change = () => tenant.ChangeStatus(target, Clock, Actor); + + if (allowed) + { + change.Should().NotThrow(); + tenant.Status.Should().Be(target); + } + } + + [Fact] + public void An_archived_tenant_is_terminal() + { + var tenant = NewTenant(); + tenant.ChangeStatus(TenantStatus.Archived, Clock, Actor); + + var revive = () => tenant.ChangeStatus(TenantStatus.Active, Clock, Actor); + + revive.Should().Throw() + .WithMessage("*Archived*Active*"); + } + + [Fact] + public void An_active_tenant_does_not_go_back_to_trial() + { + var tenant = NewTenant(); + tenant.ChangeStatus(TenantStatus.Active, Clock, Actor); + + var back = () => tenant.ChangeStatus(TenantStatus.Trial, Clock, Actor); + + back.Should().Throw(); + } + + [Fact] + public void A_status_the_enum_does_not_define_is_refused() + { + var tenant = NewTenant(); + + var change = () => tenant.ChangeStatus((TenantStatus)999, Clock, Actor); + + change.Should().Throw(); + } + + [Fact] + public void An_archived_organization_is_terminal() + { + var organization = Organization.Create( + OrganizationId.From(Guid.Parse("22222222-2222-7222-8222-222222222222")), + Tenant, "branch", "Branch", Clock, Actor); + organization.ChangeStatus(OrganizationStatus.Archived, Clock, Actor); + + var revive = () => organization.ChangeStatus(OrganizationStatus.Active, Clock, Actor); + + revive.Should().Throw(); + } + + [Theory] + // Tenant.Slug's own documentation says "URL-safe handle. Appears in + // hostnames" and Organization's says 63 "which is a DNS label"; neither + // factory looked at the characters, and neither column carries a CHECK. + [InlineData("Acme")] + [InlineData("acme_school")] + [InlineData("acme school")] + [InlineData("acme/school")] + [InlineData("-acme")] + [InlineData("acme-")] + [InlineData("acme--school")] + public void A_slug_that_is_not_url_safe_is_refused(string slug) + { + var tenant = () => TenancyDomain.Tenant.Create(Tenant, slug, "Acme", Clock, Actor); + var organization = () => Organization.Create( + OrganizationId.From(Guid.Parse("22222222-2222-7222-8222-222222222222")), + Tenant, slug, "Acme", Clock, Actor); + + tenant.Should().Throw().WithMessage("*URL-safe*"); + organization.Should().Throw().WithMessage("*URL-safe*"); + } + + [Fact] + public void A_nil_uuid_is_not_a_tenant() + { + // TenantId.From(Guid.Empty) reports IsInitialized() == true, so the + // uninitialized guard passed it straight through and a nil-uuid tenant + // inserted — satisfying its own policy for any session whose + // app.tenant_id held the same nil. No ADR reserves the value; Packet 9 + // chooses the platform sentinel. + var create = () => TenancyDomain.Tenant.Create( + TenantId.From(Guid.Empty), "acme", "Acme", Clock, Actor); + + create.Should().Throw(); + } + + [Theory] + // The one type in the module carrying audit columns without deriving from + // AuditableEntity, and so the one that skipped its guard. The accepted actor + // then threw ValueObjectValidationException out of the Vogen EF converter at + // persist time — three layers from this call. + [InlineData(true, false)] + [InlineData(false, true)] + public void A_feature_flag_refuses_the_audit_sentinels(bool sentinelClock, bool emptyActor) + { + var at = sentinelClock ? default : Clock.UtcNow; + var by = emptyActor ? UserId.From(Guid.Empty) : Actor; + + var create = () => TenantFeatureFlag.Create(Tenant, "beta", "true", at, by); + + create.Should().Throw(); + } + + [Fact] + public void Setting_a_feature_flag_refuses_them_too() + { + var flag = TenantFeatureFlag.Create(Tenant, "beta", "true", Clock.UtcNow, Actor); + + var set = () => flag.SetValue("false", default, Actor); + + set.Should().Throw(); + } + + [Fact] + public void A_verification_error_is_bounded_by_the_length_its_column_holds() + { + var domain = TenantDomain.RequestCustomDomain( + DomainId, Tenant, "learn.acme.com", Clock, Actor); + domain.MarkVerificationStarted(Clock, Actor); + + var fail = () => domain.MarkVerificationFailed(new string('e', 1001), Clock, Actor); + + fail.Should().Throw().WithMessage("*the column holds 1000*"); + } + + private static TenancyDomain.Tenant NewTenant() => + TenancyDomain.Tenant.Create(Tenant, "acme", "Acme", Clock, Actor); + [Theory] // Every mapped text bound, asserted where the value is set. The database // reports 22001 with no property name, three layers from the call. - [InlineData(35, true)] - [InlineData(36, false)] - public void A_locale_is_bounded_by_the_length_its_column_holds(int length, bool accepted) + // + // The long value is a well-formed tag, not a run of one letter. The earlier + // version asserted `new string('l', 35)` was ACCEPTED — pinning the absence of + // the BCP-47 check that 12-localization.md says lives in application code as + // if it were the rule. + [InlineData("en-Latn-US-scouse", true)] + [InlineData("en-Latn-US-scouse-scouse-scouse-scouse", false)] + public void A_locale_is_bounded_by_the_length_its_column_holds(string locale, bool accepted) { - var create = () => TenantLocale.Create(Tenant, new string('l', length), isDefault: true); + var create = () => TenantLocale.Create(Tenant, locale, isDefault: true); if (accepted) { @@ -245,6 +431,36 @@ public void A_locale_is_bounded_by_the_length_its_column_holds(int length, bool } } + [Theory] + [InlineData("tr")] + [InlineData("tr-TR")] + [InlineData("zh-Hans")] + [InlineData("zh-Hans-CN")] + public void A_well_formed_locale_tag_is_accepted(string locale) + { + var create = () => TenantLocale.Create(Tenant, locale, isDefault: true); + + create.Should().NotThrow(); + } + + [Theory] + // Well-formedness lives here because + // docs/architecture/12-localization.md says the column bounds the length and + // nothing else — "validated in application code, not by this column". + [InlineData("lllllllllllllllllllllllllllllllllll")] // 35 letters, and not a tag + [InlineData("t")] + [InlineData("tr_TR")] + [InlineData("tr-")] + [InlineData("-tr")] + [InlineData("tr TR")] + [InlineData("123")] + public void A_locale_that_is_not_a_bcp47_tag_is_refused(string locale) + { + var create = () => TenantLocale.Create(Tenant, locale, isDefault: true); + + create.Should().Throw().WithMessage("*BCP-47*"); + } + [Theory] [InlineData(200, true)] [InlineData(201, false)] diff --git a/docs/decisions/0037-idempotency-key-contract.md b/docs/decisions/0037-idempotency-key-contract.md index ced03e21..55a32263 100644 --- a/docs/decisions/0037-idempotency-key-contract.md +++ b/docs/decisions/0037-idempotency-key-contract.md @@ -28,9 +28,9 @@ Accepted implementation. - **The durable store is a schema change, so its contract has to be right first.** The in-memory default is instance-local and dies with the process. - The **table** lands with the tenancy schema in Packet 6, and its columns, its - transaction boundary and its RLS policy are this ADR's decisions in DDL form - (see Amendment 1 for why the *implementation* does not land with it). Getting the port shape wrong now means a migration + The Postgres-backed implementation lands with the tenancy schema in Packet 6, + and its columns, its transaction boundary and its RLS policy are this ADR's + decisions in DDL form. Getting the port shape wrong now means a migration later against rows that already exist. ## Considered Options @@ -241,10 +241,10 @@ in a process and do not survive into a table. ### The durable store -The table lands in [Packet 6](../roadmap/phase-02a-kernel-tenancy.md) as a -**tenant-owned, tenant-wide table**; `PostgresIdempotencyStore` itself lands when -that table's ADR-0035 trigger fires (Amendment 1). Three things about it are -decided here because the port shape depends on them: +The Postgres-backed implementation lands in +[Packet 6](../roadmap/phase-02a-kernel-tenancy.md) as a **tenant-owned, +tenant-wide table**, and three things about it are decided here because the +port shape depends on them: - **Its own transaction, and its own tenant setting.** A claim is taken before the action runs, so it is outside the MediatR @@ -427,8 +427,11 @@ Notes, in the same document, gives the ADR-0035 four-part gating precisely: owning phase Packet 6, "**which ships the table**", trigger "the first endpoint carrying `[Idempotent]` … or the first deployment running more than one instance". [Standards 20 § Demand-gated building blocks](../standards/20-infrastructure-stack.md) -carries the same row. **The gating row is right**; the two prose sentences were -loose, and are corrected above. +carries the same row. **The gating row is right**; the two prose sentences are +loose, and are superseded by this amendment — read both as *the table* lands with +the tenancy schema in Packet 6. `PostgresIdempotencyStore` itself lands when the +gating row's trigger fires. The body is left as written, per +[Documentation Standards § ADR Amendments](../standards/13-documentation.md). The distinction is the one ADR-0035 exists to draw. The **table** is one-way-door schema: adding it later means a migration against a system that has already been diff --git a/docs/decisions/0040-ambient-unit-of-work.md b/docs/decisions/0040-ambient-unit-of-work.md index 84f8f29c..a184c35f 100644 --- a/docs/decisions/0040-ambient-unit-of-work.md +++ b/docs/decisions/0040-ambient-unit-of-work.md @@ -321,20 +321,21 @@ a property it cannot exercise. ## Architecture Tests -None of these exist yet. The first two are **Phase 02a Packet 6 deliverables**; -the third is registered in Packet 6 and **backfilled in Phase 03**, because no -module code exists for it to scan until the second module does. Registering all -three in -[21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md) -is part of shipping them. +The first two are **Phase 02a Packet 6 deliverables** and shipped with it; the +third is registered in Packet 6 and **backfilled in Phase 03**, because no module +code exists for it to scan until the second module does. All three are registered +in +[21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md), +which carries their status. - `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork` — no `DbContext` registration configures its own connection string; every one goes through the - shared helper. + shared helper. **Shipped**, Packet 6. - `TransactionBehavior_Does_Not_Reference_A_Module_Assembly` — the behavior - names `IUnitOfWork` and no `DbContext`. + names `IUnitOfWork` and no `DbContext`. **Shipped**, Packet 6. - `Modules_Do_Not_Parallelize_Over_The_Ambient_Connection` — no module code passes two `DbContext`-bound operations to `Task.WhenAll` / `Task.WhenAny`. + **Awaiting backfill**, Phase 03. ## Amendments diff --git a/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md b/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md new file mode 100644 index 00000000..69247539 --- /dev/null +++ b/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md @@ -0,0 +1,159 @@ +# ADR-0041: Correcting False Statements in Accepted ADRs + +## Status + +Proposed + +**Date:** 2026-08-28 **Deciders:** @platform + +## Decision Drivers + +- **The written rule and the practised rule disagree, and both have a + constituency.** + [Documentation Standards § ADR Amendments](../standards/13-documentation.md) + says Accepted ADRs are "otherwise immutable" and that clarifications go in + dated Amendments at the bottom of the file. The corpus does something else and + has for months: + [ADR-0003 Amendment 3](0003-tenant-isolation-defense-in-depth.md) replaced a + wrong RLS template inside § Decision outcome, + [ADR-0017](0017-tenant-organization-hierarchy.md) superseded a namespace inside + the same section, [ADR-0023 Amendment 2](0023-strongly-typed-id-source-generator.md) + did the same for a cross-cutting identifier list, and + [ADR-0031 Amendment 1](0031-postgresql-major-version.md) corrected a function + name at six carriers. Every one of them disclosed the edit in a dated + amendment. None of them was permitted by the standard as written. +- **The two rules protect different things.** Immutability protects the + *record*: what was decided, on what evidence, by whom — so a later reader can + audit the reasoning rather than a rewritten version of it. Correction protects + the *reader*: an ADR is not an archive, it is the document an engineer opens + before writing a migration. +- **The harm is asymmetric, and it is measurable.** ADR-0031 named PostgreSQL + 18's UUIDv7 generator `gen_uuid_v7()`. No such function exists — + `SELECT gen_uuid_v7()` on `postgres:18.4-alpine` is + `ERROR: function gen_uuid_v7() does not exist`; the name is `uuidv7()`. Five + documents repeated it. Under strict immutability all six keep the wrong name + in their body forever, and the next engineer writing a `DEFAULT` clause copies + a function that does not exist out of the document the standard tells them to + read. An amendment at the bottom of a 200-line ADR does not reach a reader who + landed on § Decision Drivers from a search hit. +- **"Does not change the decision" is not a workable test on its own.** It is + the test the standard already uses for amendments, and it is the test every + edit above claimed. Without a bound on *what kind* of statement may be + corrected, it licenses rewriting the prose around a decision until the record + no longer shows what was actually argued. + +## Considered Options + +### Option A — Strict immutability + +No edit to an Accepted ADR's body, ever. Corrections live only in amendments. + +- **For:** the record is exactly what was accepted. No judgement call, no + reviewer argument, nothing to police. +- **Against:** the body keeps stating things that are false, and a reader who + does not scroll to the bottom acts on them. It also requires reverting four + existing amendments' worth of work and restoring a broken RLS template and a + non-existent SQL function to the documents an engineer reads first. + +### Option B — Bounded correction, disclosed by amendment + +An Accepted ADR's body may be corrected in place for a **false statement of +verifiable fact**, and for nothing else. The correction is recorded in a dated +Amendment that says what was wrong, how it was measured, and every carrier +changed. + +- **For:** matches what the corpus already does, keeps the reader safe, and + bounds the licence to a class a reviewer can check — a symbol either exists or + it does not. +- **Against:** the class needs a definition sharp enough to argue about, and a + reviewer has to apply it. + +### Option C — Correct anything that does not change the decision + +- **For:** simplest to state. +- **Against:** it is the rule the corpus has been applying informally, and this + PR is what it produced: two paragraphs of § Decision Drivers and § The durable + store in [ADR-0037](0037-idempotency-key-contract.md) rewritten to argue a + distinction the original author had not drawn. The decision was indeed + unchanged. The record of how it was argued was not. + +## Decision + +**Option B.** An Accepted ADR's body may be corrected in place when, and only +when, the text is a **false statement of verifiable fact** — one that can be +shown false by running something, opening a file, or following a link: + +- a symbol, function, type, file, table or column that does not exist under the + name given (`gen_uuid_v7()`); +- a link that does not resolve, or resolves to a document that no longer covers + the subject; +- SQL, code or configuration that does not do what the surrounding prose says it + does (ADR-0003's permissive policy pair); +- an enumeration that has drifted from a list this corpus declares canonical + elsewhere. + +Everything else — reasoning, framing, tone, trade-offs, consequences, anything +that is a judgement rather than a fact, and **any text in § Decision or +§ Decision outcome that states the decision itself** — is immutable. It changes +by superseding ADR, or not at all. + +Three obligations attach to every in-place correction: + +1. **A dated Amendment**, at the bottom, naming what was wrong, how it was shown + wrong (the command, the query, the file), and **every carrier changed** — + inside this ADR and outside it. The amendment is the record; the edit alone is + not. +2. **The decision is restated as unchanged** in that amendment. If it cannot be, + the correction is out of scope and the change is a superseding ADR. +3. **The reviewer checks the class, not the intent.** "Does this text assert + something that is verifiably false?" is the question. A reviewer who has to + reason about whether the meaning shifted is looking at an edit that does not + qualify. + +Where the correction would change how the decision *reads* rather than what it +*names* — [ADR-0037](0037-idempotency-key-contract.md)'s two paragraphs are the +worked example — the body stays as written and the amendment carries the +corrected reading. That ADR is restored to its accepted text in the same PR that +raises this one. + +## Consequences + +### Positive + +- The documents an engineer opens before writing a migration stop naming + functions that do not exist and templates that leak across tenants. +- The four existing in-place corrections become compliant rather than tolerated, + and their amendments become the model rather than the exception. +- The class is narrow enough to check in review without a debate about intent. + +### Negative + +- A reader who wants the text exactly as accepted has to read `git log -L` on the + section. The amendment names the carriers, so the trail exists, but it is a + trail rather than the document. +- Two rules now govern ADR edits where one did before, and the boundary between + them is a judgement in the small number of cases that sit near it. + +### Neutral + +- No existing ADR is superseded. ADR-0003, ADR-0017, ADR-0023 and ADR-0031 keep + their amendments; this ADR is what they were tacitly relying on. + +## Implementation Notes + +- [Documentation Standards § ADR Amendments](../standards/13-documentation.md) + is rewritten to carry both rules, citing this ADR. +- [CLAUDE.md § Things to never do](../../CLAUDE.md) currently states the + prohibition without the exception; it gains the bound. +- No architecture test can enforce this — the class is semantic. It is a review + rule, enumerated in + [Code Review Standards](../standards/17-code-review.md) alongside the other + documentation gates. + +## References + +- [Documentation Standards](../standards/13-documentation.md) — the rule this ADR amends. +- [ADR-0003 Amendment 3](0003-tenant-isolation-defense-in-depth.md) — the RLS template correction. +- [ADR-0031 Amendment 1](0031-postgresql-major-version.md) — the `uuidv7()` correction and its carrier table. +- [ADR-0023 Amendment 4](0023-strongly-typed-id-source-generator.md) — the `idempotency_keys` list correction. +- [ADR-0037 Amendment 1](0037-idempotency-key-contract.md) — the worked example of a correction that does *not* qualify. diff --git a/infra/compose/postgres-init/02-create-roles.sql b/infra/compose/postgres-init/02-create-roles.sql index 7ad11215..68707614 100644 --- a/infra/compose/postgres-init/02-create-roles.sql +++ b/infra/compose/postgres-init/02-create-roles.sql @@ -78,6 +78,24 @@ ALTER ROLE learnstack_platform WITH LOGIN PASSWORD :'platform_pw' ALTER ROLE learnstack_outbox_admin WITH LOGIN PASSWORD :'outbox_pw' BYPASSRLS NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION; +-- And revoke every role MEMBERSHIP the four hold. Attributes were only half of +-- it: `GRANT learnstack_platform TO learnstack_app` leaves learnstack_app's own +-- rolbypassrls false and still lets it `SET ROLE learnstack_platform` — measured, +-- directly and through a bridge role holding the membership on its behalf. None +-- of the four needs to be a member of anything; every privilege they hold is +-- granted to them directly, in the migration that creates each table. +-- +-- Generated per membership rather than written as four REVOKEs, because the +-- grantor is not knowable in advance and a bridge role has no fixed name. No +-- rows means no statements, so a clean cluster is a no-op. +SELECT format('REVOKE %I FROM %I', granted.rolname, member.rolname) +FROM pg_auth_members am +JOIN pg_roles granted ON granted.oid = am.roleid +JOIN pg_roles member ON member.oid = am.member +WHERE member.rolname IN ('learnstack_migration', 'learnstack_app', + 'learnstack_platform', 'learnstack_outbox_admin') +\gexec + -- No `GRANT learnstack_platform TO learnstack_app`, ever. Membership would make -- BYPASSRLS a standing capability of the application role, reachable from any -- code path that can execute `SET ROLE` — and a plain SET ROLE survives COMMIT on diff --git a/scripts/connection-string.awk b/scripts/connection-string.awk new file mode 100644 index 00000000..933850d4 --- /dev/null +++ b/scripts/connection-string.awk @@ -0,0 +1,84 @@ +# Reads an Npgsql connection string on stdin; writes one field on stdout. +# +# awk -f scripts/connection-string.awk -v field=user < value # the role +# awk -f scripts/connection-string.awk -v field=redacted < value # safe to echo +# +# Why a script and not two inline expressions in the `migrate` recipe: both +# passes need the SAME keyword table, and the version that had two of them had +# two different ones. It recognised `Username=` and `Password=` only, so a +# perfectly valid `.env` written as `UID=learnstack_app;PWD=...` — Npgsql accepts +# both, measured against Npgsql 10 — read the role as empty AND printed the +# password unredacted, in the one target whose whole purpose is keeping that +# credential in one place. +# +# The runtime half of this check lives in C#, in +# `PersistenceCompositionExtensions.BuildApplicationDataSource`, where a real +# `NpgsqlConnectionStringBuilder` does the parsing and no keyword table is +# needed. `make migrate` cannot reach it: `dotnet ef` constructs its context +# through the design-time factory, which never runs the composition root. So the +# table is duplicated here on purpose, and +# `Migrate_Target_Refuses_An_Aliased_Runtime_Credential` executes this recipe +# against the aliases to keep the two halves honest. + +BEGIN { + RS = ";" + ORS = "" + + # Every alias Npgsql 10 parses into Username, and into Password, with the + # spaces stripped and lowercased: `User Name`, `USERID` and `Pwd` are all + # here. Measured by round-tripping each through NpgsqlConnectionStringBuilder, + # not read off a documentation page. + user["username"] = 1 + user["userid"] = 1 + user["uid"] = 1 + + secret["password"] = 1 + secret["psw"] = 1 + secret["pwd"] = 1 + + found = 0 +} + +{ + key = $0 + sub(/=.*/, "", key) + gsub(/[ \t\r\n]/, "", key) + key = tolower(key) + + if (field == "user") { + if (key in user && !found) { + value = $0 + sub(/^[^=]*=/, "", value) + gsub(/^[ \t\r\n]+|[ \t\r\n]+$/, "", value) + print value + found = 1 + } + next + } + + if (NR > 1) { + print ";" + } + + if (key in secret) { + token = $0 + sub(/=.*/, "=***", token) + print token + next + } + + # No keyword to key on in a URI-style DSN — `postgres://role:secret@host/db` + # keeps its password in the userinfo. Npgsql rejects that form outright, so + # the value is on its way to an error message either way; the userinfo goes + # whole rather than by halves, because a value that did not parse gives + # nothing to be confident about. + token = $0 + gsub(/:\/\/[^@]*@/, "://***@", token) + print token +} + +END { + if (field == "user" && found) { + print "\n" + } +} From 6504540eb81b15f22a72ce798309c626aa5c5d86 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 28 Aug 2026 21:00:32 +0300 Subject: [PATCH 26/35] docs(standards): correct what the corpus says about what it ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documentation half of the review round, each item verified against the tree rather than against the sentence next to it. The Markdown link audit reported "No changed Markdown files" and exited 0 whenever its base did not resolve — which is every `workflow_dispatch` run, because `github.event.before` is empty there. It now resolves the base first, falls back to the whole tree, and echoes what it audited. The ADR template's placeholder links became placeholder text, because the full-tree pass is right to say a link to `NNNN-related.md` is broken. `05-database.md` said the roles script's re-run is a no-op. It is not, and the distinction is the point: the ALTER block re-asserts passwords and attributes and revokes memberships unconditionally, which is what makes re-applying the file the recovery path for a drifted credential or an out-of-band escalation. The Tenancy module spec described a containment the code does not implement — four entities with public factories, their own DbSets and no navigation from the root. The paragraph now says so, and Packet 7 owns the decision, because Packet 7 writes the first command that touches them. Three skills told a reader to run things that do not exist: `pnpm test:a11y` and `pnpm test:e2e`, a restore from a directory with no solution and no package.json, `ILearnStackModule`, `ModuleConventionsTests.cs`, an `overview.md`, and an `AsTenant` helper the same document had already declared nonexistent. Corrected or marked as intended shape with the phase that owns it. Also: two carriers still describing CI's Leakwatch pin as v1.5.0 when it is v1.8.0, the bare-number `ADR:` trailer form that makes `git log --grep` work, and the whitespace `git diff --check` flags. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-backend-module/SKILL.md | 31 +++++---- .claude/skills/add-integration-test/SKILL.md | 12 ++-- .claude/skills/commit-and-pr/SKILL.md | 2 +- .claude/skills/run-tests-locally/SKILL.md | 64 +++++++++---------- .githooks/pre-commit | 9 +-- .github/CONTRIBUTING.md | 8 +-- .github/workflows/ci.yml | 20 +++++- backend/.editorconfig | 1 - .../0039-optimistic-concurrency-token.md | 2 +- docs/decisions/template.md | 11 +++- docs/modules/tenancy/README.md | 25 ++++++-- docs/roadmap/phase-02a-kernel-tenancy.md | 10 +++ docs/standards/05-database.md | 16 ++++- 13 files changed, 140 insertions(+), 71 deletions(-) diff --git a/.claude/skills/add-backend-module/SKILL.md b/.claude/skills/add-backend-module/SKILL.md index 2004268b..d9222b94 100644 --- a/.claude/skills/add-backend-module/SKILL.md +++ b/.claude/skills/add-backend-module/SKILL.md @@ -95,10 +95,18 @@ Forbidden references (architecture test will catch them): ### Step 3: Author the `IModule` registration +> **The shape, not today's API.** `IModule`, `AddMediatRFromModule`, +> `IPermissionRegistry`, `IAuditCatalog` and the `modules.Add(...)` call site do +> not exist yet — the registration seam lands with **Phase 02a Packet 9**, which +> is what ships `IAuditStore` and the audit catalogue, and the permission +> registry with it. `AddModuleDbContext` **does** exist and the warning below +> it is live today. Until Packet 9, a module registers its handlers and +> validators from the composition root directly. + In `LearnStack.Modules..Application/Module.cs`: ```csharp -public sealed class Module : ILearnStackModule +public sealed class Module : IModule { public void Register(IServiceCollection services, IConfiguration configuration) { @@ -176,23 +184,22 @@ module — not one global. ### Step 5: Architecture test fixture -Add the module to the architecture-test list under -`backend/tests/LearnStack.Tests.Architecture/ModuleConventionsTests.cs`. The test -asserts: - -- The four packages exist with the right dependency direction. -- No forbidden cross-module references. -- The module's audit-coverage matrix file exists at - `docs/modules//audit.md`. -- The module's permission matrix file exists at - `docs/modules//permissions.md`. +The dependency-direction and cross-module rules live in +`backend/tests/LearnStack.Tests.Architecture/ModuleDependencyTests.cs` and are +**scanned**, not listed — a new module needs no edit there. What is still owed is +`Every_Module_Has_An_AuditCoverage_Matrix`, registered in +[21-architecture-tests-catalogue.md](../../../docs/standards/21-architecture-tests-catalogue.md) +and **awaiting backfill in Packet 9** with the audit catalogue it reads. Until it +exists, the two matrix files below are a review check rather than a test. ### Step 6: Module spec files Per [13-documentation.md § Per-Module Specifications](../../../docs/standards/13-documentation.md), create the spec files under `docs/modules//`: -- `overview.md` — what the module owns and does not own. +- `README.md` with an `## Overview` section — what the module owns and does not + own. (The standard names the *section*, not a filename; the one shipped spec, + `docs/modules/tenancy/README.md`, is the model.) - `audit.md` — audit-coverage matrix (use the [add-audit-coverage](../add-audit-coverage/SKILL.md) skill). - `permissions.md` — permission matrix (use the diff --git a/.claude/skills/add-integration-test/SKILL.md b/.claude/skills/add-integration-test/SKILL.md index 8f3f87e9..98b0654d 100644 --- a/.claude/skills/add-integration-test/SKILL.md +++ b/.claude/skills/add-integration-test/SKILL.md @@ -309,12 +309,16 @@ container. - **Skipping the isolation pair.** Architecture tests catch the *policy*; only an integration test catches the *semantic* leak. -- **Forgetting `using fixture.AsTenant(...)`.** Without it, `app.tenant_id` is - unset and queries return empty — which can mask a missing filter / RLS. +- **Forgetting the tenant statement.** `SchemaQueries.SetTenantAsync(connection, + transaction, tenantId)` is the transaction's first statement today; without it + `app.tenant_id` is unset and every tenant-owned table returns empty — which + reads exactly like "there is no data" and masks a missing filter or policy. + (The `fixture.AsTenant(...)` helper in the Step 3-5 samples is the shape those + phases will provide, not today's API.) - **Asserting on row count without `AsNoTracking`.** EF's change tracker can hold a stale instance; use `AsNoTracking()` in reads after writes. -- **Sharing seed across tenants.** Always seed per-tenant inside an `AsTenant` - block; cross-tenant seed creates ambiguity. +- **Sharing seed across tenants.** Always seed per-tenant inside a transaction + that has issued its own tenant statement; cross-tenant seed creates ambiguity. - **In-memory DB substitution.** Forbidden for integration tests; RLS doesn't run. - **One test asserting six things.** Prefer one assertion per test for triage. - **Container reuse without reset.** The fixture handles cleanup; don't roll diff --git a/.claude/skills/commit-and-pr/SKILL.md b/.claude/skills/commit-and-pr/SKILL.md index c9414e91..46deacdb 100644 --- a/.claude/skills/commit-and-pr/SKILL.md +++ b/.claude/skills/commit-and-pr/SKILL.md @@ -81,7 +81,7 @@ Trailers go at the **end** of the body (after a blank line). The supported set: | Trailer | When | |---------|------| -| `ADR: NNNN[, NNNN]` | The commit implements or derives from one or more ADRs. | +| `ADR: NNNN[, NNNN]` | The commit implements or derives from one or more ADRs. **Bare numbers** — `ADR: 0040, 0003` — never `ADR: ADR-0040`: `git log --grep='ADR: 0017'` is what the trailer exists for, and the prefixed form does not match it. A `feat` commit that creates a schema an ADR decides carries this too; the trailer is about the *derivation*, not the commit type. | | `Module: ` | Multi-module change; list every module touched. | | `I18n: ` | Added / renamed / removed user-facing i18n keys. | | `Co-Authored-By: …` | Required for AI-assisted commits. | diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index 4c4bff2d..06a79383 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -32,9 +32,9 @@ flags, and a triage map for the most common failure shapes. | Input | Required | Description | |-------|----------|-------------| -| Suite | Yes | `unit` / `integration` / `architecture` / `e2e`. | +| Suite | Yes | `unit` / `integration` / `architecture` / `contract` / `frontend`. (`e2e` arrives in Phase 02d.) | | Filter | No | `--filter ` to run a subset. | -| Docker available? | Integration (`Requires=Docker`) / E2E | A running daemon. Postgres only — no Valkey, no Kafka. | +| Docker available? | Integration (`Requires=Docker`) | A running daemon. Postgres only — no Valkey, no Kafka. | ## Workflow @@ -46,11 +46,13 @@ dotnet --version # 10.0.x node --version # 20+ for the frontend pnpm --version -# Restore (locked) -dotnet restore --locked-mode -pnpm install --frozen-lockfile +# Restore — from the directories that hold the solution and the workspace. +# There is no solution and no package.json at the repository root, so both +# commands fail there. `make install` runs exactly these two lines. +(cd backend && dotnet restore LearnStack.slnx) +(cd frontend && pnpm install --frozen-lockfile) -# Docker must be running for integration / E2E +# Docker must be running for the integration suite's Database/ cases docker info >/dev/null && echo "docker OK" ``` @@ -65,7 +67,10 @@ backend/tests/ # learnstack_app), split by [Trait("Requires","Docker")]. LearnStack.Tests.Contract/ # OpenAPI / SDK contract assertions. -frontend/apps/web/ # Vitest + axe-core + Playwright (E2E). +frontend/apps/web/ # Vitest. The axe-core and Playwright suites + # arrive with the first content-bearing pages + # in Phase 02d, which is also when CI's + # `lighthouse budget` job stops being deferred. ``` ### Step 3: Run unit tests @@ -137,20 +142,16 @@ Common failure shapes: ```bash cd frontend/apps/web pnpm test # vitest -pnpm test:a11y # axe-core -pnpm test:e2e # playwright (requires backend running) +pnpm typecheck # tsc --noEmit +pnpm lint # next lint — what `pnpm -r lint` runs in CI ``` -For E2E: - -```bash -# Terminal 1 -make dev # brings up backend + frontend via docker-compose - -# Terminal 2 -cd frontend/apps/web -pnpm test:e2e -``` +> **`pnpm test:a11y` and `pnpm test:e2e` do not exist yet.** `package.json` +> defines `dev`, `build`, `start`, `lint`, `typecheck` and `test`, and neither +> `axe-core` nor `@playwright/test` is a dependency. Both arrive in **Phase 02d** +> with the first content-bearing public pages — the same phase that activates +> CI's deferred `lighthouse budget` job. Until then there is no accessibility or +> end-to-end gate to run. ### Step 7: Single-test focus @@ -174,12 +175,6 @@ pnpm test usage-meter # path filter pnpm test -t "shows danger tone" # name filter ``` -`playwright`: - -```bash -pnpm exec playwright test e2e/enrollment.spec.ts --headed --debug -``` - ### Step 8: Coverage (optional) ```bash @@ -199,9 +194,8 @@ Application ≥ 80%, Infrastructure ≥ 50%. CI fails on regression. # Pull the exact branch CI ran git checkout -# Use the same locked deps -dotnet restore --locked-mode -pnpm install --frozen-lockfile +# The same restore CI does +make install # Run the same command CI ran (see .github/workflows/*.yml) dotnet test backend/tests/LearnStack.Tests.Integration \ @@ -221,12 +215,14 @@ dotnet test --blame-hang --blame-hang-timeout 5min `pnpm --version` CI uses. - For integration suites, Docker is running and nothing else holds 5432. - A failing test message points at the specific rule / scenario it violates. -- For frontend changes, `pnpm test:a11y` is clean — `axe-core` violations fail - the test by design. +- For frontend changes, `pnpm test`, `pnpm lint` and `pnpm typecheck` are clean. + The accessibility gate joins this list in Phase 02d, with the suite that + enforces it. ## Common pitfalls -- **No Docker.** Integration / E2E require it. Start Docker Desktop first. +- **No Docker.** The `Requires=Docker` integration cases need it. Start Docker + Desktop first, or run `--filter "Requires!=Docker"`. - **Stale lockfile.** `pnpm install --frozen-lockfile` after a `package.json` change — the lockfile must match. - **Skipped architecture test.** Forbidden. If a test is marked `[Skip]`, treat @@ -236,7 +232,9 @@ dotnet test --blame-hang --blame-hang-timeout 5min publishing 5432 from `make dev`. - **`--no-build` after a source change.** Drop the flag — the test would run against stale binaries. -- **Ignoring `axe-core` failures.** Accessibility violations are blocking per - [16-accessibility.md](../../../docs/standards/16-accessibility.md). +- **Assuming an accessibility gate exists.** + [16-accessibility.md](../../../docs/standards/16-accessibility.md) makes WCAG + 2.2 AA binding, and Phase 02d is what makes a suite enforce it. Reading the + standard is the gate until then. - **CI-only failures.** Usually a race or timing assumption. Use `--blame-hang` + `--blame-crash` locally. diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 424d909f..2f99ed8c 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -167,10 +167,11 @@ leakwatch_path_ignored() { return 1 } -# Older builds only accept a DIRECTORY target. CI pins v1.5.0, which rejects a -# file with "source validation failed: source is not a directory" — so a -# developer who mirrors the CI pin gets a hook that aborts every commit. Probe -# once and degrade to a message rather than blocking; CI is the real gate. +# Builds before v1.6.0 only accept a DIRECTORY target, rejecting a file with +# "source validation failed: source is not a directory" — so a developer on one +# of them gets a hook that aborts every commit. CI pins v1.8.0, which accepts +# files, so the probe covers the local build only. Probe once and degrade to a +# message rather than blocking; CI is the real gate. leakwatch_takes_files() { local probe diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 9759c2fa..1aedea5e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -130,10 +130,10 @@ doing exactly that. Nothing verifies its output outside the frontend workspace anyway, since no CI job runs prettier and `make format` invokes it from `frontend/`. -Older Leakwatch builds — including the `v1.5.0` CI pins — only accept a -directory target. The hook detects that and skips the local scan with an -upgrade hint rather than failing your commit; CI scans the whole tree either -way. +Leakwatch builds before v1.6.0 only accept a directory target. CI pins v1.8.0, +which accepts files; the hook detects an older *local* build and skips the scan +with an upgrade hint rather than failing your commit. CI scans the whole tree +either way. The secret scanner is [Leakwatch](https://github.com/HodeTech/leakwatch) — MIT licensed, verifier-equipped, hybrid Aho-Corasick + regex + entropy diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fda0160f..1e7da2a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -373,11 +373,27 @@ jobs: else base="${PUSH_BEFORE_SHA}" fi - changed=$(git diff --name-only "$base"...HEAD -- '*.md' || true) + # Resolve the base before diffing against it. `|| true` on the diff + # made an unresolvable base indistinguishable from a clean one: the + # step printed "No changed Markdown files" and exited 0. Reachable on + # two of the three triggers — workflow_dispatch leaves + # github.event.before empty, and a force-push to main leaves it + # pointing at a commit no ref reaches — so a manually dispatched run + # audited nothing, silently, every time. + if ! git rev-parse --verify --quiet "${base}^{commit}" >/dev/null; then + echo "Base '${base}' does not resolve; auditing every tracked Markdown file." + base="" + fi + if [[ -n "$base" ]]; then + changed=$(git diff --name-only "$base"...HEAD -- '*.md') + else + changed=$(git ls-files -- '*.md') + fi if [[ -z "$changed" ]]; then - echo "No changed Markdown files." + echo "No changed Markdown files (base: ${base:-})." exit 0 fi + echo "Auditing $(printf '%s\n' "$changed" | wc -l | tr -d ' ') file(s) against base '${base:-}'." broken=0 while IFS= read -r f; do # `]\(([^)#][^)]*)\)` — capture every non-anchor link target. diff --git a/backend/.editorconfig b/backend/.editorconfig index a47a6374..adad03a2 100644 --- a/backend/.editorconfig +++ b/backend/.editorconfig @@ -50,4 +50,3 @@ dotnet_diagnostic.CA1707.severity = none # the repo-wide `charset = utf-8`, and stripping it by hand is an edit the next # `dotnet ef migrations add` undoes. charset = utf-8-bom - diff --git a/docs/decisions/0039-optimistic-concurrency-token.md b/docs/decisions/0039-optimistic-concurrency-token.md index 44e4a14c..f5633df6 100644 --- a/docs/decisions/0039-optimistic-concurrency-token.md +++ b/docs/decisions/0039-optimistic-concurrency-token.md @@ -187,7 +187,7 @@ routing `SoftDelete` through the same stamp-and-increment primitive. Until these land the corpus answers the question twice, and an implementer reading a standard rather than this ADR gets the withdrawn answer: - | Carrier | What it still says | + | Carrier | What it still says | |---|---| | [Database Standards § Concurrency](../standards/05-database.md) | "`row_version bigint` (incremented by an EF interceptor) … `xmin`-based tokens are an alternative; pick one project-wide" — wrong on the mechanism and still offering the rejected option | | [Database Standards § Audit Columns](../standards/05-database.md) | "A shared EF interceptor populates these on `SaveChanges`" — no such interceptor exists, and [ADR-0033](0033-audit-durability-model.md) reserves the only sanctioned `SaveChanges` interceptor for snapshot capture, which writes nothing | diff --git a/docs/decisions/template.md b/docs/decisions/template.md index a7a870a6..8f4fe5e7 100644 --- a/docs/decisions/template.md +++ b/docs/decisions/template.md @@ -82,7 +82,12 @@ decision itself changes, write a new ADR that supersedes this one. ## References -- [Related ADR](NNNN-related.md) -- [Related architecture doc](../architecture/NN-related.md) -- [Related standard](../standards/NN-related.md) + + +- Related ADR — link to `NNNN-related.md` +- Related architecture doc — link to `../architecture/NN-related.md` +- Related standard — link to `../standards/NN-related.md` - External link (optional). diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index f0918228..859d3e66 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -49,10 +49,27 @@ Tenancy owns **who a request belongs to** and nothing about what they do with it ## Entity-relationship diagram -Aggregate roots are `Tenant` and `Organization`. `TenantDomain`, `TenantSetting`, -`TenantLocale` and `TenantFeatureFlag` are entities inside the Tenant aggregate. -`PlatformHostMapping` and `PlatformEntitlement` are projections rather than -aggregates — nothing in this module mutates them through a root. +Aggregate roots are `Tenant` and `Organization` — the two that implement +`IAggregateRoot`. `PlatformHostMapping` and `PlatformEntitlement` are +projections rather than aggregates: nothing in this module mutates them through +a root. + +**The other four sit between the two, and the boundary is not settled.** +`TenantDomain`, `TenantSetting`, `TenantLocale` and `TenantFeatureFlag` each have +a public factory, a top-level `DbSet` on `TenancyDbContext`, and no navigation +from `Tenant` — so there is no path through a root, which +[Standards 01 § Aggregate Ownership](../../standards/01-architecture-standards.md) +requires for state changes inside an aggregate. They also split: +`TenantDomain` and `TenantSetting` are root-shaped already (a surrogate Vogen id, +`AuditableEntity`, `row_version`, their own RLS policy), while `TenantLocale` and +`TenantFeatureFlag` have composite natural keys and no id at all and therefore +cannot be `IAggregateRoot` under any reading. + +**Packet 7 decides it**, because Packet 7 writes the first command that touches +any of them and a boundary with no writer is a boundary with no evidence. Until +then nothing writes them, so the divergence costs nothing — but it is a +divergence, and this paragraph says so rather than describing a containment the +code does not implement. ```mermaid erDiagram diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index def6b1c0..f462d6df 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -469,6 +469,16 @@ read-only transaction before the lookup, because `SET LOCAL` outside a transacti block has no effect and a session-level setting would leak across a pooled connection. `app.resolving_host` is the fourth and last canonical session variable. +**The Tenancy aggregate boundary is settled here.** Packet 6 shipped +`TenantDomain`, `TenantSetting`, `TenantLocale` and `TenantFeatureFlag` with +public factories and top-level `DbSet`s and no navigation from `Tenant`, which is +not the containment +[the module spec's ERD](../modules/tenancy/README.md) described — and the two +halves do not resolve the same way: the first pair is root-shaped already, the +second cannot be a root at all under `IAggregateRoot`. Packet 7 writes the +first command that touches any of them, which is the first evidence either +reading has, so it decides and reconciles code and spec in one direction. + **Two seed tenants in unrelated domains**, each with two organizations: an English school and a **yoga studio**. This is the artefact that tests the genericity claim, and it moves here from diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 7753ed83..0bb8e151 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -553,8 +553,20 @@ is idempotent**, which the fence below is not: `CREATE ROLE` has no `IF NOT EXISTS`, so each statement is generated by `SELECT format(…) WHERE NOT EXISTS (SELECT FROM pg_roles …) \gexec`. The fence states the *model* — which roles, which attributes, which grants; the script is -the executable form and `TheRolesScriptIsIdempotent` proves the re-run is a -no-op. It also revokes `CONNECT, TEMPORARY` from `PUBLIC` before granting, which +the executable form. + +**Idempotent is not the same as convergent, and the script is both.** The +`\gexec` creates fire only when a role is absent; the `ALTER ROLE` block that +follows runs unconditionally, re-asserting each password and each of +`NOBYPASSRLS` / `NOSUPERUSER` / `NOCREATEDB` / `NOCREATEROLE` / `NOREPLICATION`, +and then revoking every role membership the four roles hold. That is what makes +re-applying the file the documented recovery path for a drifted password, an +out-of-band `SUPERUSER`, or a `GRANT learnstack_platform TO learnstack_app` — +the last of which leaves `learnstack_app`'s own attributes reading correctly and +still puts every policy in the database one `SET ROLE` away from inert. +`TheRolesScriptIsIdempotent` proves a re-run does not error; +`TheRolesScriptTakesBackAnEscalatedAttribute` and +`TheRolesScriptRevokesAnEscalatedMembership` prove it converges. It also revokes `CONNECT, TEMPORARY` from `PUBLIC` before granting, which the fence omits and without which the grants add nothing: measured, `learnstack_app` could otherwise connect to every database in the cluster. Measured, it works under the entrypoint's `ON_ERROR_STOP=1`, and an **unset** variable leaves the placeholder unbound and aborts init rather than From 8155ba4196c07ba3c9c5896a2e3850d84aec43fc Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 29 Aug 2026 04:15:15 +0300 Subject: [PATCH 27/35] fix(kernel): redact a quoted value whole, and stamp before mutating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Npgsql accepts a semicolon inside a quoted value — measured, both quote characters, doubled quote as the escape: Host=h;Password=";secret";Database=d -> Password = ';secret' Splitting on `;` cut that in half. The first half matched the keyword table and was redacted; the second half matched nothing and was printed, so `make migrate` echoed a "redacted" string that still carried the password in full. Same class as the alias bug it was written to fix, one layer down. The parser now finds field boundaries before it looks for keywords, and four regression cases fail against the old tokenizer. Every mutator in the Tenancy module assigned its field and then called MarkUpdated — which is the only statement in those bodies that can throw. A refused audit stamp therefore left the aggregate moved, with the verification-attempt counter, which is not idempotent, already advanced. Stamp first. The bridge-role cleanup ran `REVOKE uow_bridge FROM learnstack_app` before dropping it. Measured: dropping a role clears every membership naming it in both directions, so the revoke was unnecessary — and revoking a role that does not exist ERRORS, which is exactly the state a failed setup leaves, so the unnecessary statement was also the one that would replace the real failure with a cleanup complaint. Skipped: making the migrate-target tests skip when `make` is absent. A skipped architecture test is a bug by policy, and a machine without make cannot follow the documented workflow at all. The message now names the missing tool instead of surfacing a bare Win32Exception. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-integration-test/SKILL.md | 2 - .../Organization.cs | 5 +- .../Tenant.cs | 8 +- .../TenantDomain.cs | 10 +- .../TenantSetting.cs | 3 +- .../PersistenceConventionTests.cs | 67 +++++++- .../Database/DatabaseRoleTests.cs | 15 +- .../Database/UnitOfWorkTests.cs | 9 +- .../Modules/Tenancy/TenancyAggregateTests.cs | 58 +++++++ docs/roadmap/phase-02a-kernel-tenancy.md | 3 +- scripts/connection-string.awk | 144 +++++++++++++----- 11 files changed, 269 insertions(+), 55 deletions(-) diff --git a/.claude/skills/add-integration-test/SKILL.md b/.claude/skills/add-integration-test/SKILL.md index 98b0654d..f7178522 100644 --- a/.claude/skills/add-integration-test/SKILL.md +++ b/.claude/skills/add-integration-test/SKILL.md @@ -313,8 +313,6 @@ container. transaction, tenantId)` is the transaction's first statement today; without it `app.tenant_id` is unset and every tenant-owned table returns empty — which reads exactly like "there is no data" and masks a missing filter or policy. - (The `fixture.AsTenant(...)` helper in the Step 3-5 samples is the shape those - phases will provide, not today's API.) - **Asserting on row count without `AsNoTracking`.** EF's change tracker can hold a stale instance; use `AsNoTracking()` in reads after writes. - **Sharing seed across tenants.** Always seed per-tenant inside a transaction diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs index e93c1999..66896c97 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs @@ -149,8 +149,8 @@ public void Rename(string displayName, IClock clock, UserId updatedBy) ArgumentException.ThrowIfNullOrWhiteSpace(displayName); EnsureWithinMappedLengths(Slug, displayName); - DisplayName = displayName; MarkUpdated(clock.UtcNow, updatedBy); + DisplayName = displayName; } /// @@ -170,8 +170,9 @@ public void ChangeStatus(OrganizationStatus status, IClock clock, UserId updated ArgumentNullException.ThrowIfNull(clock); EnsureTransitionAllowed(status); - Status = status; + // Stamped first — see Tenant.ChangeStatus. MarkUpdated(clock.UtcNow, updatedBy); + Status = status; } private void EnsureTransitionAllowed(OrganizationStatus target) diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs index f2fcedd9..fb9e955f 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs @@ -132,8 +132,8 @@ public void AssignDefaultOrganization(OrganizationId organizationId, IClock cloc "The default organization must be a real organization.", nameof(organizationId)); } - DefaultOrganizationId = organizationId; MarkUpdated(clock.UtcNow, updatedBy); + DefaultOrganizationId = organizationId; } /// Moves the tenant to a new lifecycle state. @@ -151,8 +151,12 @@ public void ChangeStatus(TenantStatus status, IClock clock, UserId updatedBy) ArgumentNullException.ThrowIfNull(clock); EnsureTransitionAllowed(status); - Status = status; + // Stamped before the field moves. MarkUpdated is the only statement here + // that can throw — a sentinel timestamp or an unreal actor — and an + // aggregate left mutated by a call that failed is a state no guard above + // it can see. MarkUpdated(clock.UtcNow, updatedBy); + Status = status; } private void EnsureTransitionAllowed(TenantStatus target) diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs index 3baff241..df14e3b1 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs @@ -108,8 +108,8 @@ public void MarkVerificationStarted(IClock clock, UserId updatedBy) TenantDomainStatus.Requested, TenantDomainStatus.Failed); - Status = TenantDomainStatus.Verifying; MarkUpdated(clock.UtcNow, updatedBy); + Status = TenantDomainStatus.Verifying; } /// Records a verification attempt that succeeded. @@ -119,11 +119,14 @@ public void MarkVerified(IClock clock, UserId updatedBy) EnsureVerifiable(); EnsureStatusIs("record a verification result", TenantDomainStatus.Verifying); + // Stamped first — see Tenant.ChangeStatus. Here it also protects the + // attempt counter, which is not idempotent. + MarkUpdated(clock.UtcNow, updatedBy); + Status = TenantDomainStatus.Verified; VerifiedAt = clock.UtcNow; VerificationAttempts++; LastVerificationError = null; - MarkUpdated(clock.UtcNow, updatedBy); } /// Records a verification attempt that failed. @@ -138,10 +141,11 @@ public void MarkVerificationFailed(string error, IClock clock, UserId updatedBy) EnsureVerifiable(); EnsureStatusIs("record a verification result", TenantDomainStatus.Verifying); + MarkUpdated(clock.UtcNow, updatedBy); + Status = TenantDomainStatus.Failed; VerificationAttempts++; LastVerificationError = error; - MarkUpdated(clock.UtcNow, updatedBy); } /// diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs index aa2c46ba..fcfa8d37 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs @@ -123,7 +123,8 @@ public void SetValue(string value, IClock clock, UserId updatedBy) ArgumentNullException.ThrowIfNull(clock); JsonValue.EnsureWellFormed(value, nameof(value)); - Value = value; + // Stamped first — see Tenant.ChangeStatus. MarkUpdated(clock.UtcNow, updatedBy); + Value = value; } } diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index 07dbad32..2ba42f7c 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -382,6 +382,48 @@ public void Migrate_Target_Refuses_An_Aliased_Runtime_Credential(string userKey, + "place must not echo it into a terminal or a CI log"); } + [Theory] + // Npgsql accepts a semicolon inside a quoted value — measured against + // Npgsql 10, both quote characters, with a doubled quote as the escape. The + // split-on-";" version cut such a value in half: the first half matched the + // keyword table and was redacted, the second half matched nothing and was + // printed, so `make migrate` echoed a "redacted" string that still carried + // the password. + // + // Every literal is an INPUT to the redaction under test — the file cannot be + // written without one. They name localhost and a password no service has + // ever had; leakwatch:ignore applies per line. + [InlineData("Host=localhost;Password=\";hunter2\";UID=learnstack_app")] // leakwatch:ignore + [InlineData("Host=localhost;Password=';hunter2';UID=learnstack_app")] // leakwatch:ignore + [InlineData("Host=localhost;PWD=\"a;hunter2\";Username=learnstack_app")] // leakwatch:ignore + [InlineData("Host=localhost;Password=\"said \"\"hi\"\";hunter2\";Username=learnstack_app")] // leakwatch:ignore + public void Migrate_Target_Redacts_A_Quoted_Value_Whole(string value) + { + var (exitCode, output) = RunMigrateTarget(value); + + exitCode.Should().NotBe(0, "{0}", output); + output.Should().Contain("learnstack_app", "the role is still read correctly"); + output.Should().NotContain( + "hunter2", + "a semicolon inside a quoted value is not a field boundary, so the " + + "redaction covers the value whole or it covers nothing"); + } + + [Fact] + public void Migrate_Target_Reads_The_Role_Through_A_Quoted_Value() + { + // The other half: a quoted password containing a semicolon must not + // shift the fields that follow it out of alignment, or the role check + // reads the wrong token and the recipe refuses a correct credential. + var (exitCode, output) = RunMigrateTarget( + "Host=localhost;Password=\";hunter2\";Username=learnstack_app"); // leakwatch:ignore + + exitCode.Should().NotBe(0, "{0}", output); + output.Should().Contain( + "Username='learnstack_app'", + "the field after the quoted value is still parsed as its own field"); + } + [Fact] public void Migrate_Target_Refuses_A_Uri_Without_Echoing_Its_Userinfo() { @@ -417,8 +459,29 @@ private static (int ExitCode, string Output) RunMigrateTarget(string migrationCo startInfo.ArgumentList.Add("migrate"); startInfo.Environment["ConnectionStrings__Migration"] = migrationConnectionString; - using var process = System.Diagnostics.Process.Start(startInfo) - ?? throw new InvalidOperationException("`make` did not start."); + // Not skipped when `make` is absent — a skipped architecture test is a + // bug by policy, and a machine without make cannot run `make install`, + // `make test` or `make migrate` either, so the documented workflow is + // already broken there. What is worth fixing is the message: the raw + // Win32Exception says "No such file or directory" and names nothing. + System.Diagnostics.Process process; + + try + { + process = System.Diagnostics.Process.Start(startInfo) + ?? throw new InvalidOperationException("`make` did not start."); + } + catch (System.ComponentModel.Win32Exception exception) + { + throw new InvalidOperationException( + "`make` is not on PATH. The repo-root Makefile is the documented entry " + + "point for restoring, testing and migrating (see .github/CONTRIBUTING.md " + + "§ Local checks before pushing), and this rule executes its `migrate` " + + "target to prove the credential guard refuses what it claims to.", + exception); + } + + using var _ = process; var stdout = process.StandardOutput.ReadToEnd(); var stderr = process.StandardError.ReadToEnd(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs index 32dcc347..a3c5cb95 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs @@ -335,14 +335,19 @@ await _postgres.ExecuteAsSuperuserAsync( } finally { - await _postgres.ExecuteAsSuperuserAsync( - "REVOKE learnstack_platform FROM learnstack_app"); - + // The bridge goes first: dropping it clears its memberships in both + // directions, so nothing is stranded if the revoke below is the + // statement that fails. Revoking a membership that was never granted + // only warns — measured — so the direct branch's revoke is safe to + // run unconditionally, and it is required, because learnstack_app is + // a permanent role. if (throughABridge) { - await _postgres.ExecuteAsSuperuserAsync( - $"DROP ROLE IF EXISTS {bridge}"); + await _postgres.ExecuteAsSuperuserAsync($"DROP ROLE IF EXISTS {bridge}"); } + + await _postgres.ExecuteAsSuperuserAsync( + "REVOKE learnstack_platform FROM learnstack_app"); } } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index 7a4f4e74..a8781411 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -433,8 +433,13 @@ await _schema.Postgres.ExecuteAsSuperuserAsync( } finally { - await _schema.Postgres.ExecuteAsSuperuserAsync( - "REVOKE uow_bridge FROM learnstack_app"); + // DROP alone, and no REVOKE first. Measured on PG 18.4: dropping a + // role clears every pg_auth_members row naming it, in both + // directions, so learnstack_app stops reaching the bypass role + // without a separate revoke. And `REVOKE uow_bridge FROM …` ERRORS + // when the role does not exist — which is exactly the state a failed + // setup leaves — so the unnecessary statement was also the one that + // would replace the real failure with a cleanup complaint. await _schema.Postgres.ExecuteAsSuperuserAsync("DROP ROLE IF EXISTS uow_bridge"); } } diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs index f37b0c07..fc4d7a34 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs @@ -404,6 +404,64 @@ public void A_verification_error_is_bounded_by_the_length_its_column_holds() fail.Should().Throw().WithMessage("*the column holds 1000*"); } + [Fact] + public void A_refused_audit_stamp_leaves_the_aggregate_untouched() + { + // MarkUpdated is the only statement in these mutator bodies that can + // throw. With the assignment first, a call that failed its audit + // validation still moved the aggregate — an inconsistent object no guard + // above it can see, and one EF would happily persist if a handler caught + // the ArgumentException and carried on. + var tenant = NewTenant(); + var organization = NewOrganization(); + var setting = TenantSetting.Create( + TenantSettingId.From(Guid.Parse("55555555-2222-7222-8222-222222222222")), + Tenant, null, "k", "true", Clock, Actor); + var domain = TenantDomain.RequestCustomDomain( + DomainId, Tenant, "learn.acme.com", Clock, Actor); + + var unreal = UserId.From(Guid.Empty); + + ((Action)(() => tenant.ChangeStatus(TenantStatus.Active, Clock, unreal))) + .Should().Throw(); + ((Action)(() => organization.ChangeStatus(OrganizationStatus.Suspended, Clock, unreal))) + .Should().Throw(); + ((Action)(() => organization.Rename("Renamed", Clock, unreal))) + .Should().Throw(); + ((Action)(() => setting.SetValue("false", Clock, unreal))) + .Should().Throw(); + ((Action)(() => domain.MarkVerificationStarted(Clock, unreal))) + .Should().Throw(); + + tenant.Status.Should().Be(TenantStatus.Trial, "the refused call moved nothing"); + organization.Status.Should().Be(OrganizationStatus.Active); + organization.DisplayName.Should().Be("Acme"); + setting.Value.Should().Be("true"); + domain.Status.Should().Be(TenantDomainStatus.Requested); + } + + [Fact] + public void A_refused_audit_stamp_does_not_advance_the_verification_counter() + { + // The attempt counter is the one field here that is not idempotent, so a + // partially-applied mutator is observable rather than merely untidy. + var domain = TenantDomain.RequestCustomDomain( + DomainId, Tenant, "learn.acme.com", Clock, Actor); + domain.MarkVerificationStarted(Clock, Actor); + + var fail = () => domain.MarkVerificationFailed( + "no TXT record", Clock, UserId.From(Guid.Empty)); + + fail.Should().Throw(); + domain.VerificationAttempts.Should().Be(0); + domain.LastVerificationError.Should().BeNull(); + domain.Status.Should().Be(TenantDomainStatus.Verifying); + } + + private static Organization NewOrganization() => Organization.Create( + OrganizationId.From(Guid.Parse("22222222-2222-7222-8222-222222222222")), + Tenant, "acme", "Acme", Clock, Actor); + private static TenancyDomain.Tenant NewTenant() => TenancyDomain.Tenant.Create(Tenant, "acme", "Acme", Clock, Actor); diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index f462d6df..22fbbf62 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -469,7 +469,8 @@ read-only transaction before the lookup, because `SET LOCAL` outside a transacti block has no effect and a session-level setting would leak across a pooled connection. `app.resolving_host` is the fourth and last canonical session variable. -**The Tenancy aggregate boundary is settled here.** Packet 6 shipped +**Packet 7 settles the Tenancy aggregate boundary**, which Packet 6 left +open. Packet 6 shipped `TenantDomain`, `TenantSetting`, `TenantLocale` and `TenantFeatureFlag` with public factories and top-level `DbSet`s and no navigation from `Tenant`, which is not the containment diff --git a/scripts/connection-string.awk b/scripts/connection-string.awk index 933850d4..810db1ba 100644 --- a/scripts/connection-string.awk +++ b/scripts/connection-string.awk @@ -19,9 +19,86 @@ # table is duplicated here on purpose, and # `Migrate_Target_Refuses_An_Aliased_Runtime_Credential` executes this recipe # against the aliases to keep the two halves honest. +# +# ── Why this tokenizes instead of splitting on ";" ──────────────────────────── +# Npgsql accepts a semicolon INSIDE a quoted value — measured against Npgsql 10: +# +# Host=h;Password=";secret";Database=d -> Password = ';secret' +# +# Splitting on `;` cut that value in half. The first half matched the keyword +# table and was redacted; the second half — `secret"` — matched nothing and was +# printed verbatim, so `make migrate` reported a redacted string that still +# carried the password. Redacting by keyword only works if the keywords are found +# on real field boundaries, so the boundaries are found first. + +function normalize_key(text, key) { + key = text + sub(/=.*/, "", key) + gsub(/[ \t\r\n]/, "", key) + + return tolower(key) +} + +# The value with its surrounding quotes removed and doubled inner quotes +# collapsed — Npgsql's own escaping rule. +function unquote(text, value, quote, inner) { + value = text + sub(/^[^=]*=/, "", value) + gsub(/^[ \t\r\n]+|[ \t\r\n]+$/, "", value) + + quote = substr(value, 1, 1) + + if ((quote == "\"" || quote == "'") && + length(value) > 1 && + substr(value, length(value), 1) == quote) { + inner = substr(value, 2, length(value) - 2) + gsub(quote quote, quote, inner) + + return inner + } + + return value +} + +# Splits `text` into out[1..n] on semicolons that are OUTSIDE quotes, and returns +# n. A doubled quote inside a quoted run is an escaped quote, not its end. +function split_fields(text, out, i, c, quote, current, count, length_of) { + count = 0 + current = "" + quote = "" + length_of = length(text) + + for (i = 1; i <= length_of; i++) { + c = substr(text, i, 1) + + if (quote != "") { + current = current c + + if (c == quote) { + if (substr(text, i + 1, 1) == quote) { + current = current quote + i++ + } else { + quote = "" + } + } + } else if (c == "\"" || c == "'") { + quote = c + current = current c + } else if (c == ";") { + out[++count] = current + current = "" + } else { + current = current c + } + } + + out[++count] = current + + return count +} BEGIN { - RS = ";" ORS = "" # Every alias Npgsql 10 parses into Username, and into Password, with the @@ -35,50 +112,47 @@ BEGIN { secret["password"] = 1 secret["psw"] = 1 secret["pwd"] = 1 - - found = 0 } { - key = $0 - sub(/=.*/, "", key) - gsub(/[ \t\r\n]/, "", key) - key = tolower(key) + input = input (NR > 1 ? "\n" : "") $0 +} + +END { + count = split_fields(input, fields) if (field == "user") { - if (key in user && !found) { - value = $0 - sub(/^[^=]*=/, "", value) - gsub(/^[ \t\r\n]+|[ \t\r\n]+$/, "", value) - print value - found = 1 + for (i = 1; i <= count; i++) { + if (normalize_key(fields[i]) in user) { + print unquote(fields[i]) "\n" + exit + } } - next - } - if (NR > 1) { - print ";" + exit } - if (key in secret) { - token = $0 - sub(/=.*/, "=***", token) - print token - next - } + for (i = 1; i <= count; i++) { + if (i > 1) { + print ";" + } - # No keyword to key on in a URI-style DSN — `postgres://role:secret@host/db` - # keeps its password in the userinfo. Npgsql rejects that form outright, so - # the value is on its way to an error message either way; the userinfo goes - # whole rather than by halves, because a value that did not parse gives - # nothing to be confident about. - token = $0 - gsub(/:\/\/[^@]*@/, "://***@", token) - print token -} + if (normalize_key(fields[i]) in secret) { + # The whole field, quotes included: a partial replacement is how the + # split-on-";" version leaked. + token = fields[i] + sub(/=.*/, "=***", token) + print token + continue + } -END { - if (field == "user" && found) { - print "\n" + # No keyword to key on in a URI-style DSN — `postgres://role:secret@host/db` + # keeps its password in the userinfo. Npgsql rejects that form outright, so + # the value is on its way to an error message either way; the userinfo goes + # whole rather than by halves, because a value that did not parse gives + # nothing to be confident about. + token = fields[i] + gsub(/:\/\/[^@]*@/, "://***@", token) + print token } } From 5687621b53db481119a31c2cfc7ddba95d17e834 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 29 Aug 2026 04:23:26 +0300 Subject: [PATCH 28/35] docs(decisions): rewrite ADR-0041 against what git actually shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first draft claimed four precedents for correcting an Accepted ADR's body in place. Checked with git log, three of the four are wrong, and they are wrong in the direction that mattered: the corpus's dominant instrument is the one the draft dismissed. ADR-0003 Amendment 3 did not touch the Decision. The RLS template it removed sat at line 53 of the pre-amendment file, inside Amendment 1; ADR-0003's Decision block has never been edited and the ADR has no "Decision outcome" heading. An amendment corrected an amendment. ADR-0017 did not replace the wrong namespace. It is still there, at 0017:154, and that line has never been edited — Amendment 2 added a dated banner above the fence. An inline erratum, which is a different instrument and a weaker one, and it is what the reader actually meets. ADR-0023 Amendment 2 touched no body text at all. What the draft missed: commit a1ad5fb edited ADR-0023's Implementation Notes in place fifteen months ago with no amendment anywhere, and nothing caught it. So the practice being legitimised has been used once, not four times. The decision inverts: inline erratum is the default; replacement is licensed only where a banner cannot reach the reader — text meant to be copied, or a token carried by a file that cannot hold one. The other half of the rewrite is the time axis. "A false statement of verifiable fact" licensed correcting a link that had gone stale and a list that had drifted — both true when written. Rewriting those is not fixing an error, it is rewriting history to match today. The bound is now falsity **at acceptance**, with link retargeting carved out as maintenance. Also: two mechanical review gates instead of "check the class, not the intent"; Status, Date, Deciders and all rationale placed outside the licence entirely; negative examples; a Context section; the acceptance-commit checklist grown from three files to fourteen, including the four carriers nobody had listed and the one frozen record that must not be touched. ADR-0031 Amendment 1's own citation of ADR-0003 as precedent is corrected here, because it is a claim about git history that git history refutes. ADR: 0041, 0031 Co-Authored-By: Claude Opus 5 (1M context) --- .../0031-postgresql-major-version.md | 21 +- ...cting-false-statements-in-accepted-adrs.md | 355 ++++++++++++------ docs/decisions/README.md | 3 +- 3 files changed, 259 insertions(+), 120 deletions(-) diff --git a/docs/decisions/0031-postgresql-major-version.md b/docs/decisions/0031-postgresql-major-version.md index 93d62c51..6ac4ef35 100644 --- a/docs/decisions/0031-postgresql-major-version.md +++ b/docs/decisions/0031-postgresql-major-version.md @@ -192,12 +192,21 @@ wrong, and it was wrong in the one place a spelling matters: a `DEFAULT` clause in a migration. [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) writes the first such clause, which is why this surfaced now. -Following the precedent of -[ADR-0003 Amendment 3](0003-tenant-isolation-defense-in-depth.md) — where wrong -content inside an Accepted ADR was corrected in place and the correction -recorded as an amendment rather than left to propagate — the name is corrected -at every carrier rather than annotated at each one. The carriers, so the edit is -recorded rather than silent: +The name is corrected at every carrier rather than annotated at each one, +because one of the carriers is C# — `IGuidFactory.cs`'s XML remarks — and a +Markdown erratum cannot sit beside it. + +This ADR previously cited +[ADR-0003 Amendment 3](0003-tenant-isolation-defense-in-depth.md) as precedent +for "wrong content inside an Accepted ADR corrected in place". **That citation +was false**, and git says so: the RLS template it removed sat at line 53 of the +pre-amendment file, inside `## Amendment 1 — Organization scope (2026-05-18)`. +ADR-0003's `## Decision` block has never been edited, and the ADR has no section +named "Decision outcome". An amendment corrected an amendment; no accepted +Decision body was touched. +[ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md) is where the +instruments are told apart. The carriers, so the edit is recorded rather than +silent: | Carrier | Occurrences | |---|---| diff --git a/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md b/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md index 69247539..764be305 100644 --- a/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md +++ b/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md @@ -6,154 +6,283 @@ Proposed **Date:** 2026-08-28 **Deciders:** @platform +## Context + +[Documentation Standards § ADR Amendments](../standards/13-documentation.md) +says an Accepted ADR is "otherwise immutable" and that clarifications go in dated +Amendments appended at the bottom. Twelve other tracked documents restate the +prohibition, four of them as a hard review blocker. + +The corpus does not obey it, and the first draft of this ADR was wrong about how +it disobeys. That draft claimed four precedents for correcting an Accepted ADR's +body in place. Checked against `git log`, the four are four *different* +instruments, and only one is the one claimed: + +| Case | What actually happened | +|---|---| +| ADR-0003 Amendment 3 | The wrong RLS template sat inside **Amendment 1**, at line 53 of an 89-line file. `## Decision` (lines 7–17) was never touched, and ADR-0003 has no `Decision outcome` heading. An amendment corrected an amendment. | +| ADR-0017 Amendment 2 | The wrong namespace is **still there** — `docs/decisions/0017-tenant-organization-hierarchy.md:154` carries the original `LearnStack.Modules.Identity.Domain.Entities`, and that line has never been edited. Amendment 2 added a dated banner above the fence. An **inline erratum**. | +| ADR-0023 Amendment 2 | Touched no body text at all: a single insertion hunk. | +| ADR-0031 Amendment 1 | Genuinely replaced text in accepted body sections, at six carriers, in one commit, with a table naming each. | + +So the practice this ADR was written to legitimise has been used **once**. The +instrument the corpus actually reaches for is the one the first draft dismissed. + +Two further facts the check turned up, both relevant to the decision: + +- **An undisclosed in-place edit exists.** Commit `a1ad5fb` (PR #6, 2026-05-21) + added `UserId` to the cross-cutting value-object list in ADR-0023's + § Implementation Notes — an Accepted ADR, edited in place, with no amendment + anywhere. Nobody recorded it and no review caught it. That is what the + prohibition is for, and it is also evidence that a prohibition nothing enforces + does not prevent the thing. +- **ADR-0023 Amendment 4, on this branch, edits § Implementation Notes in place** + to remove `idempotency_keys` from a list. It is disclosed, but it is the same + instrument as ADR-0031 Amendment 1, not the ADR-0017 one. + ## Decision Drivers -- **The written rule and the practised rule disagree, and both have a - constituency.** - [Documentation Standards § ADR Amendments](../standards/13-documentation.md) - says Accepted ADRs are "otherwise immutable" and that clarifications go in - dated Amendments at the bottom of the file. The corpus does something else and - has for months: - [ADR-0003 Amendment 3](0003-tenant-isolation-defense-in-depth.md) replaced a - wrong RLS template inside § Decision outcome, - [ADR-0017](0017-tenant-organization-hierarchy.md) superseded a namespace inside - the same section, [ADR-0023 Amendment 2](0023-strongly-typed-id-source-generator.md) - did the same for a cross-cutting identifier list, and - [ADR-0031 Amendment 1](0031-postgresql-major-version.md) corrected a function - name at six carriers. Every one of them disclosed the edit in a dated - amendment. None of them was permitted by the standard as written. -- **The two rules protect different things.** Immutability protects the - *record*: what was decided, on what evidence, by whom — so a later reader can - audit the reasoning rather than a rewritten version of it. Correction protects - the *reader*: an ADR is not an archive, it is the document an engineer opens - before writing a migration. -- **The harm is asymmetric, and it is measurable.** ADR-0031 named PostgreSQL - 18's UUIDv7 generator `gen_uuid_v7()`. No such function exists — - `SELECT gen_uuid_v7()` on `postgres:18.4-alpine` is - `ERROR: function gen_uuid_v7() does not exist`; the name is `uuidv7()`. Five - documents repeated it. Under strict immutability all six keep the wrong name - in their body forever, and the next engineer writing a `DEFAULT` clause copies - a function that does not exist out of the document the standard tells them to - read. An amendment at the bottom of a 200-line ADR does not reach a reader who - landed on § Decision Drivers from a search hit. -- **"Does not change the decision" is not a workable test on its own.** It is - the test the standard already uses for amendments, and it is the test every - edit above claimed. Without a bound on *what kind* of statement may be - corrected, it licenses rewriting the prose around a decision until the record - no longer shows what was actually argued. +- **The written rule and the practised rule disagree, and the practised rule is + not what anyone assumed.** Three instruments are in use — erratum, + amendment-editing-an-amendment, and replacement — and no document distinguishes + them. +- **The two rules protect different people.** Immutability protects the record: + what was decided, on what evidence, by whom, so a later reader can audit the + reasoning rather than a rewritten version of it. Correction protects the + engineer who opens the ADR before writing a migration. +- **The harm is asymmetric and measurable in one direction only.** ADR-0031 named + PostgreSQL 18's UUIDv7 generator `gen_uuid_v7()`. Measured on + `postgres:18.4-alpine`: `SELECT gen_uuid_v7()` is + `ERROR: function gen_uuid_v7() does not exist`, and `pg_proc` holds no such + function even with all 46 bundled extensions installed. The name is `uuidv7()`. + Five documents repeated the wrong one, including `IGuidFactory.cs`'s XML + remarks. Under strict immutability all six keep it. +- **"Does not change the decision" is not a workable test alone.** It is the test + amendments already use, and it is the test every edit above claimed. Without a + bound on *what kind* of statement may be touched it licenses rewriting the + prose around a decision until the record no longer shows what was argued. +- **Nor is "verifiable fact" alone.** A statement can be verifiably false today + and have been true on the day it was accepted. Correcting that is not fixing an + error; it is rewriting history to match the present, which is the exact harm + immutability exists to prevent. ## Considered Options ### Option A — Strict immutability -No edit to an Accepted ADR's body, ever. Corrections live only in amendments. +No edit to an Accepted ADR's body, ever. Corrections live only in amendments at +the bottom of the file. -- **For:** the record is exactly what was accepted. No judgement call, no - reviewer argument, nothing to police. -- **Against:** the body keeps stating things that are false, and a reader who - does not scroll to the bottom acts on them. It also requires reverting four - existing amendments' worth of work and restoring a broken RLS template and a - non-existent SQL function to the documents an engineer reads first. +- **For:** the record is exactly what was accepted. No judgement call. +- **Against:** a reader who arrives at § Decision Drivers from a search hit never + scrolls to the bottom, and acts on the false statement. It also requires + reverting ADR-0031 Amendment 1 and restoring a non-existent SQL function to six + documents. -### Option B — Bounded correction, disclosed by amendment +### Option B — Bounded in-place replacement, disclosed by amendment -An Accepted ADR's body may be corrected in place for a **false statement of -verifiable fact**, and for nothing else. The correction is recorded in a dated -Amendment that says what was wrong, how it was measured, and every carrier -changed. +The first draft's answer: replace the false text, record it in a dated amendment +naming every carrier. -- **For:** matches what the corpus already does, keeps the reader safe, and - bounds the licence to a class a reviewer can check — a symbol either exists or - it does not. -- **Against:** the class needs a definition sharp enough to argue about, and a - reviewer has to apply it. +- **For:** the reader sees only correct text. +- **Against:** it is the strongest instrument, and it was chosen without noticing + that the corpus's own dominant precedent is weaker. Applied by default it + destroys the accepted wording in every case, including the many where a banner + would have served. ### Option C — Correct anything that does not change the decision - **For:** simplest to state. -- **Against:** it is the rule the corpus has been applying informally, and this - PR is what it produced: two paragraphs of § Decision Drivers and § The durable - store in [ADR-0037](0037-idempotency-key-contract.md) rewritten to argue a - distinction the original author had not drawn. The decision was indeed - unchanged. The record of how it was argued was not. +- **Against:** this is what the corpus was doing informally, and PR #14 is what it + produced: two paragraphs of [ADR-0037](0037-idempotency-key-contract.md)'s + § Decision Drivers and § The durable store rewritten to argue a distinction the + original author had not drawn. The decision was indeed unchanged. The record of + how it was argued was not. + +### Option D — Inline erratum, with replacement as a bounded exception + +Keep the accepted text. Put a dated erratum immediately beside it, so a reader who +never scrolls still sees it. Record the correction in a dated Amendment. Replace +the text only where an erratum cannot reach the reader. + +- **For:** it is what [ADR-0017](0017-tenant-organization-hierarchy.md) already + does, and it protects both constituencies at once — the accepted wording + survives in the document rather than only in `git log -L`, and the reader is + warned at the point of reading. +- **Against:** the document grows, and the exception still needs a boundary sharp + enough to argue about. ## Decision -**Option B.** An Accepted ADR's body may be corrected in place when, and only -when, the text is a **false statement of verifiable fact** — one that can be -shown false by running something, opening a file, or following a link: - -- a symbol, function, type, file, table or column that does not exist under the - name given (`gen_uuid_v7()`); -- a link that does not resolve, or resolves to a document that no longer covers - the subject; -- SQL, code or configuration that does not do what the surrounding prose says it - does (ADR-0003's permissive policy pair); -- an enumeration that has drifted from a list this corpus declares canonical - elsewhere. - -Everything else — reasoning, framing, tone, trade-offs, consequences, anything -that is a judgement rather than a fact, and **any text in § Decision or -§ Decision outcome that states the decision itself** — is immutable. It changes -by superseding ADR, or not at all. - -Three obligations attach to every in-place correction: - -1. **A dated Amendment**, at the bottom, naming what was wrong, how it was shown - wrong (the command, the query, the file), and **every carrier changed** — - inside this ADR and outside it. The amendment is the record; the edit alone is - not. +**Option D.** + +### The default: inline erratum + +An Accepted ADR's body is **not edited**. Where it carries a statement that was +false when the ADR was accepted, add a dated erratum immediately adjacent to it — +before the paragraph, or before the fence — and record the correction in a dated +Amendment. The corrected value lives in whichever standard or architecture +document is the operational authority for it. + +### The exception: in-place replacement + +Replacement is licensed only when **all three** hold: + +1. **The statement was false when the ADR was accepted** — not true then and + stale now. Evidence is a command and its output, pinned where possible to the + authority as it stood on the acceptance date. +2. **An erratum cannot protect the reader**, because the text travels away from + its banner. That means either: + - the text is meant to be **executed or copied elsewhere** — a template, a DDL + or config fence, a command, a symbol a reader will type into code; or + - the same false token is carried by **more than one document**, at least one + of which cannot hold a Markdown banner (source, script, workflow). +3. **The diff adds and removes no normative content** — no obligation, scope, + alternative, rationale or consequence. + +ADR-0003 Amendment 3 meets (2): an RLS template exists to be copied, and had in +fact been copied into four documents, wrong in all four. ADR-0031 Amendment 1 +meets (2) by the second limb: six carriers, one of them C# XML remarks. ADR-0017's +namespace meets neither limb — nobody lifts a namespace declaration out of an ADR +into a migration — and an erratum is what it correctly got. + +### What is never touched + +- **§ Status, § Date, § Deciders.** A Status change is a lifecycle event recorded + as such, never a fact correction. +- **Rationale, framing, trade-offs, judgements.** "This approach performs better", + the sizing of a consequence, the weighing of an option — none of these is a + verifiable fact, and all of them are immutable. They change by superseding ADR. +- **Anything in § Decision that states the decision itself.** Where a correction + would change how the decision *reads* rather than what it *names*, the body + stays and the amendment carries the reading. ADR-0037 Amendment 1 is the worked + example: its two paragraphs were rewritten in this branch and restored in the + same commit that raises this ADR, so ADR-0037's body at `HEAD` is byte-identical + to its text on `main`. + +### What is not a correction at all + +- **A link whose target moved.** Retargeting the URL with the link text unchanged + is maintenance: nothing the ADR asserts changes, and no amendment is owed. +- **A statement that has gone stale.** A list that has drifted since acceptance, a + file that has since been renamed, a document that no longer covers the subject — + these were true when written. They are history. They get an amendment, or a + superseding ADR, never a rewrite. + +### The obligations + +Both instruments carry the same three: + +1. **A dated Amendment** naming what was wrong, **how it was shown wrong** (the + command, the query, the file), and **every carrier changed**, inside this ADR + and outside it. Where the correction is an enumeration, the amendment names the + document the corpus treats as canonical for that list. The amendment is the + record; the edit alone is not. 2. **The decision is restated as unchanged** in that amendment. If it cannot be, - the correction is out of scope and the change is a superseding ADR. -3. **The reviewer checks the class, not the intent.** "Does this text assert - something that is verifiably false?" is the question. A reviewer who has to - reason about whether the meaning shifted is looking at an edit that does not - qualify. - -Where the correction would change how the decision *reads* rather than what it -*names* — [ADR-0037](0037-idempotency-key-contract.md)'s two paragraphs are the -worked example — the body stays as written and the amendment carries the -corrected reading. That ADR is restored to its accepted text in the same PR that -raises this one. + the change is a superseding ADR. +3. **Two gates in review, checked separately.** First: is there reproducible + evidence the statement was false *at acceptance*? Second: does the diff move + any normative content? A reviewer who has to reason about whether the meaning + shifted is looking at an edit that does not qualify. ## Consequences ### Positive - The documents an engineer opens before writing a migration stop naming - functions that do not exist and templates that leak across tenants. -- The four existing in-place corrections become compliant rather than tolerated, - and their amendments become the model rather than the exception. + functions that do not exist, and the accepted wording survives in the document + rather than only in `git log -L`. +- The corpus's three instruments become three named instruments with a rule for + choosing between them, instead of one prohibition and three undisclosed + departures from it. - The class is narrow enough to check in review without a debate about intent. ### Negative -- A reader who wants the text exactly as accepted has to read `git log -L` on the - section. The amendment names the carriers, so the trail exists, but it is a - trail rather than the document. -- Two rules now govern ADR edits where one did before, and the boundary between - them is a judgement in the small number of cases that sit near it. +- Errata accumulate in the body of a long-lived ADR, and a reader meets the + correction before the thing corrected. +- Two rules now govern ADR edits where one did before, and the boundary is a + judgement in the small number of cases near it. + +### What accepting this costs on this branch + +Named so the cost is visible before the decision, not after: + +- **[ADR-0023](0023-strongly-typed-id-source-generator.md) Amendment 4** removed + `idempotency_keys` from a list in § Implementation Notes in place. It fails + exception limb (2) — a list of tables is not copied and has one carrier — so it + becomes an erratum. +- **[ADR-0031](0031-postgresql-major-version.md) Amendment 1** stands under limb + (2)'s second clause, but its own prose cites ADR-0003 Amendment 3 as precedent + for correcting "wrong content inside an Accepted ADR **in place**". That is + false: ADR-0003's edit was inside Amendment 1. The citation is corrected in the + same commit that raises this ADR, because it is a statement about git history + that git history refutes. +- **Commit `a1ad5fb`'s undisclosed edit to ADR-0023** is owed a retroactive + amendment. Nothing enforces the prohibition today, which is why it survived + fifteen months unnoticed. ### Neutral -- No existing ADR is superseded. ADR-0003, ADR-0017, ADR-0023 and ADR-0031 keep - their amendments; this ADR is what they were tacitly relying on. +- No existing ADR is superseded. ## Implementation Notes -- [Documentation Standards § ADR Amendments](../standards/13-documentation.md) - is rewritten to carry both rules, citing this ADR. -- [CLAUDE.md § Things to never do](../../CLAUDE.md) currently states the - prohibition without the exception; it gains the bound. -- No architecture test can enforce this — the class is semantic. It is a review - rule, enumerated in - [Code Review Standards](../standards/17-code-review.md) alongside the other - documentation gates. +Accepting this ADR is one commit that touches **fourteen files**. The rule is +stated in seventeen sentences across thirteen tracked files; the first draft named +three of them. + +**Rewrites — the rule changes:** + +| File | Sites | +|---|---| +| [13-documentation.md](../standards/13-documentation.md) | six, not one; § ADR Amendments is only the largest | +| [CLAUDE.md](../../CLAUDE.md) | two — § Things to never do, and the § Documentation layout table row | +| [decisions/README.md](README.md) | "Accepted ADRs are not rewritten" — today it does not even carry the amendment escape | +| [.github/CONTRIBUTING.md](../../.github/CONTRIBUTING.md) | § Never | +| [standards-check/SKILL.md](../../.claude/skills/standards-check/SKILL.md) | a hard blocker checklist item, which would mechanically block a compliant correction | +| [write-adr/SKILL.md](../../.claude/skills/write-adr/SKILL.md) | four sentences | +| [commit-and-pr/SKILL.md](../../.claude/skills/commit-and-pr/SKILL.md) | its parenthetical is already false today — amendments are permitted | +| [standards/README.md](../standards/README.md) | "Immutable history" | +| [21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md) | its justification for correcting ADR-0018's test names in place | + +**Additions:** + +- [17-code-review.md](../standards/17-code-review.md) has **no documentation gate + at all** — sixteen zero-tolerance rows, all code or schema; one doc-adjacent + checklist item, about OpenAPI. The rule belongs as a seventeenth § Zero + Tolerance row, and the file's `**Derives from:**` header gains ADR-0041, because + its parenthetical currently claims every blocker maps to ADR-0003 or ADR-0010. +- [decisions/template.md](template.md) states the amendment mechanism correctly + and under-specifies obligation 1; its amendment stanza gains the three things an + amendment must name. + +**Left alone, deliberately:** the Packet 5 delivery record in +[phase-02a-kernel-tenancy.md](../roadmap/phase-02a-kernel-tenancy.md) argues from +ADR body immutability. It is a dated delivery record and is not rewritten +([CLAUDE.md § Documentation layout](../../CLAUDE.md)); it becomes +historically-true-and-stale, which is what a frozen record is for. + +**Enforcement.** The class test is semantic and no architecture test can check it. +A narrower *disclosure* check is buildable in the CI meta job, against its existing +base-resolution machinery: an Accepted ADR changed in a diff that adds no dated +amendment anywhere is a failure. It cannot check the class, and it cannot key on +"the pre-amendment portion" — three ADRs put amendments in the top third of the +file, and two use `## Amendment N` with no container — so it enforces only that +something was disclosed. That is still more than the zero enforcement the +prohibition has today, which is what let `a1ad5fb` through. ## References -- [Documentation Standards](../standards/13-documentation.md) — the rule this ADR amends. -- [ADR-0003 Amendment 3](0003-tenant-isolation-defense-in-depth.md) — the RLS template correction. -- [ADR-0031 Amendment 1](0031-postgresql-major-version.md) — the `uuidv7()` correction and its carrier table. -- [ADR-0023 Amendment 4](0023-strongly-typed-id-source-generator.md) — the `idempotency_keys` list correction. -- [ADR-0037 Amendment 1](0037-idempotency-key-contract.md) — the worked example of a correction that does *not* qualify. +- [Documentation Standards](../standards/13-documentation.md) — the rule this ADR + amends. +- [ADR-0017 Amendment 2](0017-tenant-organization-hierarchy.md) — the inline + erratum this ADR makes the default. +- [ADR-0003 Amendment 3](0003-tenant-isolation-defense-in-depth.md) — the RLS + template correction; an amendment correcting an amendment. +- [ADR-0031 Amendment 1](0031-postgresql-major-version.md) — the `uuidv7()` + correction and its carrier table. +- [ADR-0023 Amendment 4](0023-strongly-typed-id-source-generator.md) — the + `idempotency_keys` list correction. +- [ADR-0037 Amendment 1](0037-idempotency-key-contract.md) — the worked example of + a correction that does *not* qualify. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 09c20270..1fbfdda6 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -9,7 +9,7 @@ This directory contains LearnStack ADRs. Each ADR captures a one-time decision w - **Superseded** — Replaced by a newer ADR. Kept as a redirect. - **Deprecated** — No longer applies. Kept for history. -Accepted ADRs are not rewritten. A new decision is a new ADR, possibly superseding the old one. +Accepted ADRs are not rewritten. A new decision is a new ADR, possibly superseding the old one. Dated Amendments, appended at the bottom, are the one sanctioned way to add to an accepted record — see [Documentation Standards § ADR Amendments](../standards/13-documentation.md). Whether an Accepted ADR's **body** may ever be corrected, and with which instrument, is the open question in [ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md). ## Active ADRs @@ -52,6 +52,7 @@ Accepted ADRs are not rewritten. A new decision is a new ADR, possibly supersedi | 0038 | [Cross-Cutting Port and Event Contracts](0038-cross-cutting-port-and-event-contracts.md) | **Supersedes ADR-0014.** Retains Dapr behind demand gates; fixes the event envelope, handler isolation, trace/audit scope and cache contracts | | 0039 | [The Optimistic Concurrency Token](0039-optimistic-concurrency-token.md) | An explicit `row_version bigint` (CLR `long`), project-wide, incremented inside `AuditableEntity` by the single primitive `MarkUpdated` and `SoftDelete` both route through, and mapped with `HasDefaultValue(0L).IsConcurrencyToken().ValueGeneratedNever()` — exactly those three calls (Amendment 2): `ValueGeneratedOnAddOrUpdate()` / `IsRowVersion()` make EF omit the column from the `UPDATE` entirely (Amendment 1, measured), and `HasDefaultValue` alone leaves `ValueGenerated` at `OnAdd`, which the registered rule rejects; `xmin` is rejected because the token is client-visible through ETag / `If-Match` and a dump-restore or logical-replication cutover changes it — measured, unlike the widely-cited `VACUUM FREEZE` objection, which does not | | 0040 | [The Ambient Unit of Work](0040-ambient-unit-of-work.md) | One `DbConnection` per scope, owned by `IUnitOfWork`; every module `DbContext`, `IAuditStore` and `IOutbox` enlists on it, because `SET LOCAL` is connection-local and a context on its own connection reads **zero rows** under the corrected RLS policy. Spans one aggregate's write plus audit plus outbox plus any reads — cross-aggregate writes stay forbidden. Defines nesting, disposal, and the event-consumer entry point that never reaches MediatR | +| 0041 | [Correcting False Statements in Accepted ADRs](0041-correcting-false-statements-in-accepted-adrs.md) | **Proposed — awaiting decision.** Inline erratum beside the false text is the default; in-place replacement only where an erratum cannot reach the reader (text meant to be copied, or a token carried by a file that cannot hold a Markdown banner). Bounded to statements that were false **when the ADR was accepted** — a statement that has since gone stale is history, and gets an amendment or a superseding ADR | ## Superseded ADRs From e27355c2fa58985da7a592af635d419089e50881 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 29 Aug 2026 10:11:21 +0300 Subject: [PATCH 29/35] docs(decisions): bound ADR-0041 by canonicity, not by who might copy it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round. The blocker's premise does not hold, three of its majors do, and one of them breaks the rule against its own example. Refuted: "the commit rewrites Accepted ADR-0031's existing Amendment 1 while ADR-0041 is only Proposed". That amendment is new on this branch — origin/main's ADR-0031 has no amendments section at all — so the text has never been part of the accepted record and no instrument was owed. The same argument that clears ADR-0039 and ADR-0040 clears this. The replacement limb was wrong, and wrong against ADR-0017, which the ADR itself holds up as the erratum precedent: a namespace in a C# fence IS a symbol someone could copy, so "meant to be executed or copied elsewhere" covered it. The test is not copyability but canonicity — text the corpus presents as an artifact for reuse. ADR-0003's RLS template is that by construction; ADR-0017's fence is an illustrative sketch and its own amendment says so. The second limb went entirely. That a source file cannot hold a Markdown banner is an argument for correcting the source file, which immutability never bound; it is not a licence to edit an ADR body alongside it. Each carrier is judged on its own. This reverses part of ADR-0031 Amendment 1's sweep, and the Consequences now say so instead of grandfathering it. The acceptance clock was the wrong clock. ADR-0003's wrong SQL entered through Amendment 1, months after acceptance; the test is now falsity when a statement entered the record, judged per statement. Also: the default instrument gets a written shape, because "add an erratum" without a format is three formats; Option A's cost was overstated, since immutability binds ADR bodies and not the standards, index and C# that carry the same token; "fifteen months" was 99 days; the catalogue row claimed the opposite of what the catalogue says; and the CI sketch needed a Status-on-the-diff-base filter or it fails every PR that introduces an ADR. Standards 13's own Rules list turns out to permit in-place "typo fixes" with no test for what a typo is. Recorded in Context: the unbounded exception this ADR bounds is already written down. ADR: 0041 Co-Authored-By: Claude Opus 5 (1M context) --- ...cting-false-statements-in-accepted-adrs.md | 149 +++++++++++++----- 1 file changed, 109 insertions(+), 40 deletions(-) diff --git a/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md b/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md index 764be305..7f14d6ec 100644 --- a/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md +++ b/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md @@ -10,8 +10,11 @@ Proposed [Documentation Standards § ADR Amendments](../standards/13-documentation.md) says an Accepted ADR is "otherwise immutable" and that clarifications go in dated -Amendments appended at the bottom. Twelve other tracked documents restate the -prohibition, four of them as a hard review blocker. +Amendments appended at the bottom. Its § ADRs Rules list is looser than that and +than the twelve other documents restating it: "Accepted ADRs are immutable except +for **typo fixes** and dated Amendments." So an unbounded in-place exception is +already written down, in the one document that is the authority — with no test for +what counts as a typo. Four of the twelve restatements are hard review blockers. The corpus does not obey it, and the first draft of this ADR was wrong about how it disobeys. That draft claimed four precedents for correcting an Accepted ADR's @@ -55,8 +58,10 @@ Two further facts the check turned up, both relevant to the decision: `postgres:18.4-alpine`: `SELECT gen_uuid_v7()` is `ERROR: function gen_uuid_v7() does not exist`, and `pg_proc` holds no such function even with all 46 bundled extensions installed. The name is `uuidv7()`. - Five documents repeated the wrong one, including `IGuidFactory.cs`'s XML - remarks. Under strict immutability all six keep it. + Five other carriers repeated the wrong one, including `IGuidFactory.cs`'s XML + remarks. Immutability binds Accepted ADR bodies and nothing else, so under any + option the standards, the index and the C# carry the correct name; what is at + stake is only the occurrences inside ADR bodies. - **"Does not change the decision" is not a workable test alone.** It is the test amendments already use, and it is the test every edit above claimed. Without a bound on *what kind* of statement may be touched it licenses rewriting the @@ -75,9 +80,9 @@ the bottom of the file. - **For:** the record is exactly what was accepted. No judgement call. - **Against:** a reader who arrives at § Decision Drivers from a search hit never - scrolls to the bottom, and acts on the false statement. It also requires - reverting ADR-0031 Amendment 1 and restoring a non-existent SQL function to six - documents. + scrolls to the bottom, and acts on the false statement. The cost is bounded — + only the ADR-body occurrences revert, because immutability reaches no further — + but those are the occurrences a reader of the ADR meets. ### Option B — Bounded in-place replacement, disclosed by amendment @@ -119,32 +124,59 @@ the text only where an erratum cannot reach the reader. ### The default: inline erratum An Accepted ADR's body is **not edited**. Where it carries a statement that was -false when the ADR was accepted, add a dated erratum immediately adjacent to it — -before the paragraph, or before the fence — and record the correction in a dated -Amendment. The corrected value lives in whichever standard or architecture -document is the operational authority for it. +false when it entered the record, add a dated erratum immediately adjacent to it +and record the correction in a dated Amendment. The corrected value lives in +whichever standard or architecture document is the operational authority for it. + +The erratum is a blockquote, placed **before** the span it corrects — before the +paragraph, or before the fence — so a reader meets it first: + +```markdown +> **Erratum — YYYY-MM-DD.** The below reads ``. It is +> ``; shown by ``. The Decision is +> unchanged. Current authority: [](). Recorded in Amendment N. +``` + +One erratum per span, not per sentence. Where the span runs longer than a +paragraph or a fence — a subsection, a table — place the single erratum before the +subsection heading and repeat the authority link at its end, because a reader who +enters a long span from a deep link starts below the banner. ### The exception: in-place replacement Replacement is licensed only when **all three** hold: -1. **The statement was false when the ADR was accepted** — not true then and - stale now. Evidence is a command and its output, pinned where possible to the - authority as it stood on the acceptance date. -2. **An erratum cannot protect the reader**, because the text travels away from - its banner. That means either: - - the text is meant to be **executed or copied elsewhere** — a template, a DDL - or config fence, a command, a symbol a reader will type into code; or - - the same false token is carried by **more than one document**, at least one - of which cannot hold a Markdown banner (source, script, workflow). +1. **The statement was false when it entered the accepted record** — not true + then and stale now. The reference point is per statement, not per file: text + from the original body is judged at the acceptance commit, and text inside a + dated Amendment is judged at *that amendment's* date. ADR-0003 is why this + matters — its wrong SQL entered through Amendment 1, months after the ADR was + accepted, so the ADR's own acceptance date is the wrong clock. Evidence is a + command and its output, pinned where possible to the authority as it stood on + that date. +2. **The text is presented as a canonical artifact for reuse** — a template the + corpus tells other documents to copy, a DDL or config block meant to be + applied, a command meant to be run. Such text travels away from its banner by + design, and a reader who copies the fence does not copy the erratum above it. + Illustrative code is not this: a sketch that shows the shape of a type is read, + not applied, and it gets an erratum like any other prose. 3. **The diff adds and removes no normative content** — no obligation, scope, alternative, rationale or consequence. -ADR-0003 Amendment 3 meets (2): an RLS template exists to be copied, and had in -fact been copied into four documents, wrong in all four. ADR-0031 Amendment 1 -meets (2) by the second limb: six carriers, one of them C# XML remarks. ADR-0017's -namespace meets neither limb — nobody lifts a namespace declaration out of an ADR -into a migration — and an erratum is what it correctly got. +ADR-0003 Amendment 3 meets (2) exactly: the RLS template is the canonical artifact +by construction — four documents had copied it, and it was wrong in all four. +ADR-0017's namespace does not, and it is worth being precise about why, because it +sits in a C# fence and an earlier draft of this ADR classified it wrongly by +appealing to whether anyone would copy it. The distinguishing property is not +copyability but **canonicity**: ADR-0017's fence is an illustrative sketch, and the +ADR's own amendment says so, calling it "superseded as illustrative". An erratum is +what it correctly got. + +**A carrier outside the ADRs licenses nothing.** `IGuidFactory.cs` cannot hold a +Markdown banner, but that is an argument for correcting `IGuidFactory.cs` — which +is code, and which immutability never bound — not for editing an ADR body. Each +carrier is judged on its own; the existence of a source-file carrier is not a +licence to touch the ADRs alongside it. ### What is never touched @@ -189,12 +221,13 @@ Both instruments carry the same three: ### Positive -- The documents an engineer opens before writing a migration stop naming - functions that do not exist, and the accepted wording survives in the document +- The documents an engineer opens before writing a migration stop **presenting** + a function that does not exist as the current one — the erratum is met before + the text it corrects — while the accepted wording survives in the document rather than only in `git log -L`. - The corpus's three instruments become three named instruments with a rule for - choosing between them, instead of one prohibition and three undisclosed - departures from it. + choosing between them, instead of one prohibition, an untested "typo fixes" + escape, and three disclosed-but-unclassified departures. - The class is narrow enough to check in review without a debate about intent. ### Negative @@ -212,15 +245,26 @@ Named so the cost is visible before the decision, not after: `idempotency_keys` from a list in § Implementation Notes in place. It fails exception limb (2) — a list of tables is not copied and has one carrier — so it becomes an erratum. -- **[ADR-0031](0031-postgresql-major-version.md) Amendment 1** stands under limb - (2)'s second clause, but its own prose cites ADR-0003 Amendment 3 as precedent - for correcting "wrong content inside an Accepted ADR **in place**". That is - false: ADR-0003's edit was inside Amendment 1. The citation is corrected in the - same commit that raises this ADR, because it is a statement about git history - that git history refutes. +- **[ADR-0031](0031-postgresql-major-version.md) Amendment 1** is the larger cost, + and dropping the carrier clause is what makes it one. Its sweep replaced + `gen_uuid_v7()` in three ADR bodies — its own, ADR-0023's and ADR-0002's — and + in three non-ADR carriers. The three non-ADR carriers are unaffected: they are a + standard, an index and C# XML remarks, which immutability never bound and which + are simply correct now. The three ADR-body occurrences fail the canonical-artifact + test — a function named in prose is read, not applied — so under this rule they + become errata. That is a real reversal of work already done, and it is the + honest price of the rule; it is cheap here only because Amendment 1 has not + merged. +- **ADR-0031 Amendment 1's precedent citation was false and is already corrected.** + It claimed ADR-0003 Amendment 3 as precedent for correcting "wrong content + inside an Accepted ADR **in place**"; ADR-0003's edit was inside its own + Amendment 1. No instrument was owed for that correction and none was used: + Amendment 1 is **new on this branch** — `origin/main`'s ADR-0031 has no + amendments section at all — so the text has never been part of the accepted + record, and editing an unmerged draft is editing a draft. - **Commit `a1ad5fb`'s undisclosed edit to ADR-0023** is owed a retroactive - amendment. Nothing enforces the prohibition today, which is why it survived - fifteen months unnoticed. + amendment. Nothing enforces the prohibition today, which is why it survived 99 + days and a merge unnoticed. ### Neutral @@ -244,7 +288,7 @@ three of them. | [write-adr/SKILL.md](../../.claude/skills/write-adr/SKILL.md) | four sentences | | [commit-and-pr/SKILL.md](../../.claude/skills/commit-and-pr/SKILL.md) | its parenthetical is already false today — amendments are permitted | | [standards/README.md](../standards/README.md) | "Immutable history" | -| [21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md) | its justification for correcting ADR-0018's test names in place | +| [21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md) | "Every mutable carrier is corrected in place" — the blanket this ADR bounds. Note it does **not** rewrite ADR-0018, and must not start: those test names were canonicalized after acceptance, which is true-then-stale-now, not false-at-entry | **Additions:** @@ -257,6 +301,22 @@ three of them. and under-specifies obligation 1; its amendment stanza gains the three things an amendment must name. +**A retroactive amendment, owed to the record rather than to this rule:** +[ADR-0023](0023-strongly-typed-id-source-generator.md) gains an amendment dated +the acceptance commit, recording that commit `a1ad5fb` (2026-05-21) added `UserId` +to the cross-cutting value-object list in § Implementation Notes with no +disclosure. The edit itself is not undone — it is correct, and `UserId` does +belong there — but an accepted record that changed without a note is the thing +this ADR is about. + +**Glossary:** `inline erratum`, `amendment` and `in-place replacement` become +project vocabulary on acceptance, and [the glossary](../glossary.md) is the +source of truth for terms — one entry each, pointing here. + +**Verified at acceptance:** ADR-0037's body is byte-identical to its text on +`main` (sha256 of everything above `## Amendments`, both revisions), which is what +makes its worked-example status true rather than asserted. + **Left alone, deliberately:** the Packet 5 delivery record in [phase-02a-kernel-tenancy.md](../roadmap/phase-02a-kernel-tenancy.md) argues from ADR body immutability. It is a dated delivery record and is not rewritten @@ -265,13 +325,22 @@ historically-true-and-stale, which is what a frozen record is for. **Enforcement.** The class test is semantic and no architecture test can check it. A narrower *disclosure* check is buildable in the CI meta job, against its existing -base-resolution machinery: an Accepted ADR changed in a diff that adds no dated -amendment anywhere is a failure. It cannot check the class, and it cannot key on +base-resolution machinery: a file under `docs/decisions/` whose Status is already +`Accepted` **on the diff base**, changed in a diff that adds no dated amendment +anywhere, is a failure. The Status filter and the base are both load-bearing — an +ADR introduced by the same pull request has no accepted record to violate, which +is the case ADR-0039, ADR-0040 and this file are all in. + +It cannot check the class, and it cannot key on "the pre-amendment portion" — three ADRs put amendments in the top third of the file, and two use `## Amendment N` with no container — so it enforces only that something was disclosed. That is still more than the zero enforcement the prohibition has today, which is what let `a1ad5fb` through. +## Amendments + +None yet. + ## References - [Documentation Standards](../standards/13-documentation.md) — the rule this ADR From 6695e511f216141bb89567b06a684029b0519719 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 29 Aug 2026 10:15:37 +0300 Subject: [PATCH 30/35] fix(ci): let the link audit read a fenced block as sample text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `meta` job failed on ADR-0041's erratum template, which shows the shape of an erratum inside a ```markdown fence — placeholder target and all. No renderer linkifies a target inside a fence, so no such file has to exist, and the audit had no business resolving it. This is the second document the check has bent out of shape: the ADR template's `NNNN-related.md` placeholders were rewritten as prose for the same reason two days ago. When the workaround makes two templates worse at being templates, the check is the thing that is wrong. It now strips fenced blocks before extracting links, and still catches a real broken link on either side of one. The ADR template keeps its prose placeholders: those sit in a plain list, not a fence, so they are still links as far as any renderer is concerned. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e7da2a9..08c056ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -419,7 +419,16 @@ jobs: echo "BROKEN: $f → $link" broken=$((broken + 1)) fi - done < <(grep -oE '\]\(([^)#][^)]*)\)' "$f" | sed -E 's/^\]\((.+)\)$/\1/') + # Fenced code blocks are stripped first. `](…)` inside a fence + # is sample text — no renderer linkifies it, so no target has to + # exist — and the audit flagged two of them: the ADR template's + # `NNNN-related.md` placeholders and ADR-0041's erratum shape. + # Working around it in the documents made both templates worse + # at being templates, which is the sign the check was the thing + # that was wrong. + done < <(awk '/^[[:space:]]*```/ { fence = !fence; next } !fence' "$f" \ + | grep -oE '\]\(([^)#][^)]*)\)' \ + | sed -E 's/^\]\((.+)\)$/\1/') done <<< "$changed" if [[ $broken -gt 0 ]]; then echo "::error::$broken broken relative link(s) in changed Markdown." From e0b313739292a3f6394fd3581da78fbda823c479 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 29 Aug 2026 10:31:28 +0300 Subject: [PATCH 31/35] docs(decisions): make ADR-0041's own disclosure clause local to each ADR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blocker is real and it was self-inflicted. Obligation 1 let one amendment carry the carrier list for every ADR a diff touched, and the CI sketch accepted an amendment anywhere in the diff. So ADR-A and ADR-B could both change with only ADR-A amended — and if ADR-B was corrected by replacement it kept no erratum either, leaving a reader of ADR-B with no trace at all. The disclosure clause reintroduced the silent rewrite the ADR exists to stop. Disclosure is now per file, and the CI sketch with it. The retroactive amendment owed to ADR-0023 said "dated the acceptance commit", which reads as backdating it to the 2026-05-21 edit it discloses. That would manufacture a record of a disclosure that never happened. It is dated the day it is written and titled for the day it describes. The scope count did not match the reversals the ADR already accepts in Consequences: ADR-0002, ADR-0023 and ADR-0031 become errata, and the disclosure check needs ci.yml. Seventeen files, listed, countable. The index row still summarised the superseded copyability criterion, three commits after canonicity replaced it. Also: Context moved after Decision, where the template and eight of the nine most recent ADRs put it; the subsection erratum moved below its heading, because an anchor lands the viewport at the heading and a banner above it is already scrolled off; "three instruments" unified to two mechanisms, since correcting text inside an amendment is a location and not a third instrument; the four restatements that carry no amendment escape named rather than counted; and "licences" as a verb, which is wrong in both spellings the corpus uses. ADR: 0041 Co-Authored-By: Claude Opus 5 (1M context) --- ...cting-false-statements-in-accepted-adrs.md | 148 +++++++++++------- docs/decisions/README.md | 2 +- 2 files changed, 92 insertions(+), 58 deletions(-) diff --git a/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md b/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md index 7f14d6ec..4fdc5096 100644 --- a/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md +++ b/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md @@ -6,49 +6,15 @@ Proposed **Date:** 2026-08-28 **Deciders:** @platform -## Context - -[Documentation Standards § ADR Amendments](../standards/13-documentation.md) -says an Accepted ADR is "otherwise immutable" and that clarifications go in dated -Amendments appended at the bottom. Its § ADRs Rules list is looser than that and -than the twelve other documents restating it: "Accepted ADRs are immutable except -for **typo fixes** and dated Amendments." So an unbounded in-place exception is -already written down, in the one document that is the authority — with no test for -what counts as a typo. Four of the twelve restatements are hard review blockers. - -The corpus does not obey it, and the first draft of this ADR was wrong about how -it disobeys. That draft claimed four precedents for correcting an Accepted ADR's -body in place. Checked against `git log`, the four are four *different* -instruments, and only one is the one claimed: - -| Case | What actually happened | -|---|---| -| ADR-0003 Amendment 3 | The wrong RLS template sat inside **Amendment 1**, at line 53 of an 89-line file. `## Decision` (lines 7–17) was never touched, and ADR-0003 has no `Decision outcome` heading. An amendment corrected an amendment. | -| ADR-0017 Amendment 2 | The wrong namespace is **still there** — `docs/decisions/0017-tenant-organization-hierarchy.md:154` carries the original `LearnStack.Modules.Identity.Domain.Entities`, and that line has never been edited. Amendment 2 added a dated banner above the fence. An **inline erratum**. | -| ADR-0023 Amendment 2 | Touched no body text at all: a single insertion hunk. | -| ADR-0031 Amendment 1 | Genuinely replaced text in accepted body sections, at six carriers, in one commit, with a table naming each. | - -So the practice this ADR was written to legitimise has been used **once**. The -instrument the corpus actually reaches for is the one the first draft dismissed. - -Two further facts the check turned up, both relevant to the decision: - -- **An undisclosed in-place edit exists.** Commit `a1ad5fb` (PR #6, 2026-05-21) - added `UserId` to the cross-cutting value-object list in ADR-0023's - § Implementation Notes — an Accepted ADR, edited in place, with no amendment - anywhere. Nobody recorded it and no review caught it. That is what the - prohibition is for, and it is also evidence that a prohibition nothing enforces - does not prevent the thing. -- **ADR-0023 Amendment 4, on this branch, edits § Implementation Notes in place** - to remove `idempotency_keys` from a list. It is disclosed, but it is the same - instrument as ADR-0031 Amendment 1, not the ADR-0017 one. - ## Decision Drivers - **The written rule and the practised rule disagree, and the practised rule is - not what anyone assumed.** Three instruments are in use — erratum, - amendment-editing-an-amendment, and replacement — and no document distinguishes - them. + not what anyone assumed.** Two correction mechanisms are in use — inline + erratum and in-place replacement — and no document distinguishes them or says + which to reach for. (ADR-0003's case is often counted as a third; it is not. + Correcting text that sits inside an amendment is a *location*, not an + instrument: the same two mechanisms apply there, judged against that + amendment's date.) - **The two rules protect different people.** Immutability protects the record: what was decided, on what evidence, by whom, so a later reader can audit the reasoning rather than a rewritten version of it. Correction protects the @@ -138,9 +104,10 @@ paragraph, or before the fence — so a reader meets it first: ``` One erratum per span, not per sentence. Where the span runs longer than a -paragraph or a fence — a subsection, a table — place the single erratum before the -subsection heading and repeat the authority link at its end, because a reader who -enters a long span from a deep link starts below the banner. +paragraph or a fence — a subsection, a table — place the single erratum +**immediately below the heading and above the first line of content**, not above +the heading: a reader arriving on the heading's anchor starts their viewport at +the heading, so a banner placed above it is already scrolled off. ### The exception: in-place replacement @@ -175,8 +142,8 @@ what it correctly got. **A carrier outside the ADRs licenses nothing.** `IGuidFactory.cs` cannot hold a Markdown banner, but that is an argument for correcting `IGuidFactory.cs` — which is code, and which immutability never bound — not for editing an ADR body. Each -carrier is judged on its own; the existence of a source-file carrier is not a -licence to touch the ADRs alongside it. +carrier is judged on its own; the existence of a source-file carrier does not +license touching the ADRs alongside it. ### What is never touched @@ -205,18 +172,69 @@ licence to touch the ADRs alongside it. Both instruments carry the same three: -1. **A dated Amendment** naming what was wrong, **how it was shown wrong** (the - command, the query, the file), and **every carrier changed**, inside this ADR - and outside it. Where the correction is an enumeration, the amendment names the +1. **A dated Amendment in every Accepted ADR the diff changes**, naming what was + wrong, **how it was shown wrong** (the command, the query, the file), and + **every carrier changed**, inside that ADR and outside it. The cross-file + carrier list is additive, never a substitute: one amendment in ADR-A that + lists ADR-B among its carriers leaves a reader of ADR-B with no local trace at + all, and under replacement not even an erratum — which is the silent rewrite + this ADR exists to stop, reintroduced through its own disclosure clause. + Where the correction is an enumeration, the amendment names the document the corpus treats as canonical for that list. The amendment is the record; the edit alone is not. 2. **The decision is restated as unchanged** in that amendment. If it cannot be, the change is a superseding ADR. 3. **Two gates in review, checked separately.** First: is there reproducible - evidence the statement was false *at acceptance*? Second: does the diff move + evidence the statement was false *when it entered the record*? Second: does + the diff move any normative content? A reviewer who has to reason about whether the meaning shifted is looking at an edit that does not qualify. +## Context + +[Documentation Standards § ADR Amendments](../standards/13-documentation.md) +says an Accepted ADR is "otherwise immutable" and that clarifications go in dated +Amendments appended at the bottom. Its § ADRs Rules list is looser than that and +than the twelve other documents restating it: "Accepted ADRs are immutable except +for **typo fixes** and dated Amendments." So an unbounded in-place exception is +already written down, in the one document that is the authority — with no test for +what counts as a typo. + +Four of the twelve restatements go further than the authority does and state the +prohibition with **no amendment escape at all** — [decisions/README.md](README.md), +[CONTRIBUTING § Never](../../.github/CONTRIBUTING.md), +[write-adr](../../.claude/skills/write-adr/SKILL.md) in four separate sentences, +and [standards-check](../../.claude/skills/standards-check/SKILL.md), where it is a +hard blocker checklist item that would mechanically stop a correction this ADR +permits. + +The corpus does not obey it, and the first draft of this ADR was wrong about how +it disobeys. That draft claimed four precedents for correcting an Accepted ADR's +body in place. Checked against `git log`, the four are four *different* +instruments, and only one is the one claimed: + +| Case | What actually happened | +|---|---| +| ADR-0003 Amendment 3 | The wrong RLS template sat inside **Amendment 1**, at line 53 of an 89-line file. `## Decision` (lines 7–17) was never touched, and ADR-0003 has no `Decision outcome` heading. An amendment corrected an amendment. | +| ADR-0017 Amendment 2 | The wrong namespace is **still there** — `docs/decisions/0017-tenant-organization-hierarchy.md:154` carries the original `LearnStack.Modules.Identity.Domain.Entities`, and that line has never been edited. Amendment 2 added a dated banner above the fence. An **inline erratum**. | +| ADR-0023 Amendment 2 | Touched no body text at all: a single insertion hunk. | +| ADR-0031 Amendment 1 | Genuinely replaced text in accepted body sections, at six carriers, in one commit, with a table naming each. | + +So the practice this ADR was written to legitimise has been used **once**. The +instrument the corpus actually reaches for is the one the first draft dismissed. + +Two further facts the check turned up, both relevant to the decision: + +- **An undisclosed in-place edit exists.** Commit `a1ad5fb` (PR #6, 2026-05-21) + added `UserId` to the cross-cutting value-object list in ADR-0023's + § Implementation Notes — an Accepted ADR, edited in place, with no amendment + anywhere. Nobody recorded it and no review caught it. That is what the + prohibition is for, and it is also evidence that a prohibition nothing enforces + does not prevent the thing. +- **ADR-0023 Amendment 4, on this branch, edits § Implementation Notes in place** + to remove `idempotency_keys` from a list. It is disclosed, but it is the same + instrument as ADR-0031 Amendment 1, not the ADR-0017 one. + ## Consequences ### Positive @@ -225,8 +243,8 @@ Both instruments carry the same three: a function that does not exist as the current one — the erratum is met before the text it corrects — while the accepted wording survives in the document rather than only in `git log -L`. -- The corpus's three instruments become three named instruments with a rule for - choosing between them, instead of one prohibition, an untested "typo fixes" +- The corpus's two correction mechanisms become two named mechanisms with a rule + for choosing between them, instead of one prohibition, an untested "typo fixes" escape, and three disclosed-but-unclassified departures. - The class is narrow enough to check in review without a debate about intent. @@ -272,9 +290,10 @@ Named so the cost is visible before the decision, not after: ## Implementation Notes -Accepting this ADR is one commit that touches **fourteen files**. The rule is -stated in seventeen sentences across thirteen tracked files; the first draft named -three of them. +Accepting this ADR is one commit that touches **seventeen files** — the sixteen +below plus this one, whose Status flips to Accepted. The rule is stated in +seventeen sentences across thirteen tracked files; the first draft named three of +them, and counted the reversals in § Consequences without listing them. **Rewrites — the rule changes:** @@ -301,15 +320,30 @@ three of them. and under-specifies obligation 1; its amendment stanza gains the three things an amendment must name. +**Reversals this rule requires of work already on this branch** — named in +§ Consequences and listed here so the commit is buildable from this section alone: + +| File | Change | +|---|---| +| [ADR-0031](0031-postgresql-major-version.md) | its own body's `gen_uuid_v7()` occurrences become errata; Amendment 1 is rewritten to describe errata, and it is unmerged, so no instrument is owed for that | +| [ADR-0023](0023-strongly-typed-id-source-generator.md) | same, plus Amendment 4's in-place list edit becomes an erratum | +| [ADR-0002](0002-initial-architecture.md) | same, for its one occurrence — the carrier ADR-0031's table calls "the PostgreSQL row" | +| [.github/workflows/ci.yml](../../.github/workflows/ci.yml) | the disclosure check described under **Enforcement** | + **A retroactive amendment, owed to the record rather than to this rule:** [ADR-0023](0023-strongly-typed-id-source-generator.md) gains an amendment dated -the acceptance commit, recording that commit `a1ad5fb` (2026-05-21) added `UserId` -to the cross-cutting value-object list in § Implementation Notes with no +**the day it is written**, not the day of the edit it discloses — titled in the +shape `Amendment N — Retroactive disclosure of the 2026-05-21 edit (YYYY-MM-DD)`. +Backdating it to `a1ad5fb`'s own date would manufacture a record of a disclosure +that did not happen, which is the failure mode this ADR is about, committed in +the act of repairing it. It records that commit `a1ad5fb` (2026-05-21) added +`UserId` to the cross-cutting value-object list in § Implementation Notes with no disclosure. The edit itself is not undone — it is correct, and `UserId` does belong there — but an accepted record that changed without a note is the thing this ADR is about. -**Glossary:** `inline erratum`, `amendment` and `in-place replacement` become +**Glossary:** `inline erratum`, `in-place replacement`, `canonical artifact` and +`illustrative sketch` become project vocabulary on acceptance, and [the glossary](../glossary.md) is the source of truth for terms — one entry each, pointing here. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 1fbfdda6..4c1c6db0 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -52,7 +52,7 @@ Accepted ADRs are not rewritten. A new decision is a new ADR, possibly supersedi | 0038 | [Cross-Cutting Port and Event Contracts](0038-cross-cutting-port-and-event-contracts.md) | **Supersedes ADR-0014.** Retains Dapr behind demand gates; fixes the event envelope, handler isolation, trace/audit scope and cache contracts | | 0039 | [The Optimistic Concurrency Token](0039-optimistic-concurrency-token.md) | An explicit `row_version bigint` (CLR `long`), project-wide, incremented inside `AuditableEntity` by the single primitive `MarkUpdated` and `SoftDelete` both route through, and mapped with `HasDefaultValue(0L).IsConcurrencyToken().ValueGeneratedNever()` — exactly those three calls (Amendment 2): `ValueGeneratedOnAddOrUpdate()` / `IsRowVersion()` make EF omit the column from the `UPDATE` entirely (Amendment 1, measured), and `HasDefaultValue` alone leaves `ValueGenerated` at `OnAdd`, which the registered rule rejects; `xmin` is rejected because the token is client-visible through ETag / `If-Match` and a dump-restore or logical-replication cutover changes it — measured, unlike the widely-cited `VACUUM FREEZE` objection, which does not | | 0040 | [The Ambient Unit of Work](0040-ambient-unit-of-work.md) | One `DbConnection` per scope, owned by `IUnitOfWork`; every module `DbContext`, `IAuditStore` and `IOutbox` enlists on it, because `SET LOCAL` is connection-local and a context on its own connection reads **zero rows** under the corrected RLS policy. Spans one aggregate's write plus audit plus outbox plus any reads — cross-aggregate writes stay forbidden. Defines nesting, disposal, and the event-consumer entry point that never reaches MediatR | -| 0041 | [Correcting False Statements in Accepted ADRs](0041-correcting-false-statements-in-accepted-adrs.md) | **Proposed — awaiting decision.** Inline erratum beside the false text is the default; in-place replacement only where an erratum cannot reach the reader (text meant to be copied, or a token carried by a file that cannot hold a Markdown banner). Bounded to statements that were false **when the ADR was accepted** — a statement that has since gone stale is history, and gets an amendment or a superseding ADR | +| 0041 | [Correcting False Statements in Accepted ADRs](0041-correcting-false-statements-in-accepted-adrs.md) | **Proposed — awaiting decision.** Inline erratum beside the false text is the default; in-place replacement only where the text is a **canonical artifact for reuse** — a template others are told to copy, a DDL or command meant to be applied — never merely because something could be copied, and never because a non-ADR carrier holds the same token. Bounded to statements false **when they entered the record**, judged per statement: original body at the acceptance commit, amendment text at that amendment's date. A statement that has since gone stale is history, and gets an amendment or a superseding ADR. Every changed Accepted ADR carries its own dated Amendment | ## Superseded ADRs From 4cb3f8bdea5c123923d62d45c78cf0a446337a3f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 29 Aug 2026 10:46:41 +0300 Subject: [PATCH 32/35] docs(decisions): accept ADR-0041 and correct the corpus to match it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eighteen files. The rule was stated in seventeen sentences across thirteen documents, four of which went further than the authority and allowed no amendment at all — one of them a hard blocker checklist item that would have stopped a compliant correction. Standards 13 gains § Correcting and Amending ADRs, which is now the operating rule: inline erratum by default, in-place replacement only for a canonical artifact for reuse, both bounded to a statement false when it entered the record, both owing a dated Amendment in every Accepted ADR the diff changes. Its old "immutable except for typo fixes" line had an unbounded in-place exception with no test attached; that is what this replaces. The reversals the rule demands of this branch's own work: ADR-0002, ADR-0023 and ADR-0031 get their `gen_uuid_v7()` back with errata beside it, because a function named in prose is read, not applied. ADR-0023's Amendment 4 stops removing `idempotency_keys` from a list and annotates it instead. The three non-ADR carriers keep the correct name — immutability never bound a standard, an index or a C# file. ADR-0023 also gets the amendment it has been owed since 2026-05-21, when commit a1ad5fb added UserId to an Accepted ADR with no disclosure and nothing caught it for 99 days. Dated today, not then: back-dating it would manufacture evidence of a disclosure that never happened. The CI meta job gains the disclosure check — Status read on the diff base, so an ADR introduced by the same PR is exempt, and link-only edits skipped because retargeting a moved link asserts nothing new. It found a violation in its first run, in this branch: Packet 6 had rewritten a table row inside ADR-0003's Amendment 3 behind an ad-hoc note, with no Amendment. That list was true when Amendment 3 was written and went stale afterwards, which is history — the row is restored, carries an erratum, and Amendment 4 says so. The eighteenth file is that one. It is not in the ADR's own checklist because no one had found it yet. ADR: 0041 Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/commit-and-pr/SKILL.md | 5 +- .claude/skills/standards-check/SKILL.md | 8 +- .claude/skills/write-adr/SKILL.md | 27 ++++-- .github/CONTRIBUTING.md | 10 +- .github/workflows/ci.yml | 62 ++++++++++++ CLAUDE.md | 14 ++- docs/decisions/0002-initial-architecture.md | 34 ++++++- .../0003-tenant-isolation-defense-in-depth.md | 41 ++++++-- ...0023-strongly-typed-id-source-generator.md | 95 ++++++++++++++++--- .../0031-postgresql-major-version.md | 72 +++++++++----- ...cting-false-statements-in-accepted-adrs.md | 11 ++- docs/decisions/README.md | 6 +- docs/decisions/template.md | 10 +- docs/glossary.md | 10 ++ docs/standards/13-documentation.md | 39 +++++++- docs/standards/17-code-review.md | 3 +- .../21-architecture-tests-catalogue.md | 10 +- docs/standards/README.md | 2 +- 18 files changed, 386 insertions(+), 73 deletions(-) diff --git a/.claude/skills/commit-and-pr/SKILL.md b/.claude/skills/commit-and-pr/SKILL.md index 46deacdb..eb929148 100644 --- a/.claude/skills/commit-and-pr/SKILL.md +++ b/.claude/skills/commit-and-pr/SKILL.md @@ -6,7 +6,10 @@ description: > preparing a commit, opening a PR, picking the right scope for a doc-only change, adding the AI co-author trailer correctly. DO NOT USE FOR: deciding whether a change is ready to commit (that's a code-review concern, not a commit-format - concern), force-pushing, or amending an Accepted ADR (write a new ADR instead). + concern), force-pushing, or changing what an Accepted ADR decides (write a new + ADR that supersedes it — amendments and the bounded corrections in + [ADR-0041](../../../docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md) + are permitted, and are a [write-adr](../write-adr/SKILL.md) concern). --- # LearnStack commit + PR conventions diff --git a/.claude/skills/standards-check/SKILL.md b/.claude/skills/standards-check/SKILL.md index 910a65e2..e417dd05 100644 --- a/.claude/skills/standards-check/SKILL.md +++ b/.claude/skills/standards-check/SKILL.md @@ -114,8 +114,12 @@ Walk every item in [CLAUDE.md § Hard rules](../../../CLAUDE.md) and Things to never do (forbidden — block the change if any tripped): -- [ ] Edit an Accepted ADR's Decision section (add an Amendment instead, or - write a superseding ADR). +- [ ] Edit an Accepted ADR's body outside + [13-documentation.md § Correcting and Amending ADRs](../../../docs/standards/13-documentation.md): + an inline erratum by default, in-place replacement only for a canonical artifact + for reuse, both only for a statement false when it entered the record, and both + owing a dated Amendment **in every Accepted ADR the diff changes**. A changed + decision is a superseding ADR. - [ ] Introduce a 5th cross-module communication mechanism. - [ ] Add domain-specific code (CEFR, exam, English placement, kyu/dan, asana, code-challenge, …) to any module. diff --git a/.claude/skills/write-adr/SKILL.md b/.claude/skills/write-adr/SKILL.md index d2ad2350..1427f650 100644 --- a/.claude/skills/write-adr/SKILL.md +++ b/.claude/skills/write-adr/SKILL.md @@ -5,8 +5,11 @@ description: > Drivers + Considered Options) and reserve its number. USE FOR: capturing a one-time architectural decision that other docs will cite, picking between two or more incompatible technology / pattern choices, recording the reason a rule is - the way it is. DO NOT USE FOR: editing an existing Accepted ADR's Decision section - (write a new ADR that supersedes it), recording day-to-day implementation choices + the way it is. DO NOT USE FOR: changing an existing Accepted ADR's decision + (write a new ADR that supersedes it), correcting a false statement in an + Accepted ADR's body (that is an erratum or a replacement under + [ADR-0041](../../../docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md), + not a new ADR), recording day-to-day implementation choices (those go in code review / commit messages), or research notes (use `docs/analysis/`, which is gitignored). --- @@ -29,8 +32,12 @@ cross-linked. ## When not to use -- Editing the Decision section of an Accepted ADR. Write a **new** ADR that +- Changing the decision an Accepted ADR records. Write a **new** ADR that supersedes it instead. +- Correcting a statement in an Accepted ADR's body that was false when it entered + the record. That is an inline erratum, or in-place replacement where the text is + a canonical artifact for reuse — see + [13-documentation.md § Correcting and Amending ADRs](../../../docs/standards/13-documentation.md). - Implementation-level choices that fit in a commit message. - "We might do X someday" — defer until the decision is real. - Tenant-customization rule changes that are data, not code. @@ -121,8 +128,10 @@ present tense.> ## Amendments - + ``` ### Step 3: Cross-link @@ -165,8 +174,12 @@ After writing the ADR: - **One-option Considered Options.** Forces the author to compare, even briefly, against at least one rejected alternative. If you cannot name a rejected option, the decision probably isn't ADR-worthy. -- **Editing an Accepted ADR's Decision.** Add an Amendment (date + clarification), - or write a superseding ADR. Never rewrite the Decision in place. +- **Rewriting an Accepted ADR's Decision.** Add an Amendment (date + + clarification), or write a superseding ADR. The two bounded corrections + [ADR-0041](../../../docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md) + permits do not reach the decision itself: where a correction would change how + the decision *reads* rather than what it *names*, the body stays and the + amendment carries the reading. - **Reusing a number.** ADR numbers are sequential and immutable per [decisions/README.md](../../../docs/decisions/README.md). The rule is enforced by code review, not by an architecture test today; do not depend on a test to diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 1aedea5e..fac44650 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -168,5 +168,11 @@ If Leakwatch flags an intentional dev credential, prefer: around it. - Bypass the pre-commit hook (`--no-verify`) for anything but a documented emergency — CI will catch it and the PR will fail. -- Edit an Accepted ADR's Decision section. Open a new ADR that supersedes - it, with the same number rule preserved. +- Edit an Accepted ADR's body outside the two bounded mechanisms in + [Documentation Standards § Correcting and Amending ADRs](../docs/standards/13-documentation.md) + ([ADR-0041](../docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md)): + an inline erratum by default, in-place replacement only for a canonical + artifact for reuse, both only for a statement false when it entered the record, + and both owing a dated Amendment in every Accepted ADR the diff changes. A + changed decision is a new ADR that supersedes the old one, with the same number + rule preserved. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08c056ce..651fa279 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -435,6 +435,68 @@ jobs: exit 1 fi + - name: Accepted ADR disclosure + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PUSH_BEFORE_SHA: ${{ github.event.before }} + run: | + # ADR-0041: an Accepted ADR's body changes only by inline erratum or + # bounded in-place replacement, and BOTH owe a dated Amendment **in + # that ADR's own file**. The class test is semantic and no check can + # make it; this makes the disclosure. + # + # Three filters, each load-bearing: + # - Status on the DIFF BASE, not on HEAD. An ADR introduced by this + # pull request has no accepted record to violate, and flipping a + # Proposed ADR to Accepted is a lifecycle event, not a correction. + # - The file must exist on the base at all. + # - Link retargeting is explicitly not a correction (ADR-0041 + # § What is not a correction at all), so a diff that changes only + # link targets is skipped. + if [[ "$EVENT_NAME" == "pull_request" ]]; then + base="origin/${PR_BASE_REF}" + else + base="${PUSH_BEFORE_SHA}" + fi + if ! git rev-parse --verify --quiet "${base}^{commit}" >/dev/null; then + echo "Base '${base}' does not resolve; nothing to compare an ADR against." + exit 0 + fi + undisclosed=0 + while IFS= read -r f; do + [[ -n "$f" ]] || continue + # New in this PR, or deleted — no accepted record either way. + git cat-file -e "${base}:${f}" 2>/dev/null || continue + [[ -f "$f" ]] || continue + adr_status=$(git show "${base}:${f}" \ + | awk '/^## Status/ { want = 1; next } want && NF { print; exit }') + # Prefix, not equality: three Accepted ADRs carry a prose Status + # line ("Accepted (Amendment 1: … )", "Accepted with two + # amendments — …"), and an equality test skipped every one of them. + [[ "$adr_status" == Accepted* ]] || continue + # Link-target-only edits owe nothing. + if diff -q \ + <(git show "${base}:${f}" | sed -E 's/\]\([^)]*\)/](LINK)/g') \ + <(sed -E 's/\]\([^)]*\)/](LINK)/g' "$f") >/dev/null; then + continue + fi + # A dated amendment heading, added by this diff, in this file. + if git diff "$base"...HEAD -- "$f" \ + | grep -qE '^\+#{2,3} Amendment|^\+#{2,3} [0-9]{4}-[0-9]{2}-[0-9]{2}'; then + echo "ok: $f discloses its change" + else + echo "UNDISCLOSED: $f — an Accepted ADR changed with no dated Amendment in its own file." + undisclosed=$((undisclosed + 1)) + fi + done < <(git diff --name-only "$base"...HEAD -- 'docs/decisions/*.md' \ + | grep -vE '^docs/decisions/(README|template)\.md$') + if [[ $undisclosed -gt 0 ]]; then + echo "::error::$undisclosed Accepted ADR(s) changed without disclosure. See docs/standards/13-documentation.md § Correcting and Amending ADRs." + exit 1 + fi + echo "Accepted ADR disclosure: clean." + - name: docs/analysis residual scan run: | # Per CLAUDE.md: docs/analysis/ is gitignored and MUST NOT be diff --git a/CLAUDE.md b/CLAUDE.md index fbecdb69..5f663472 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,7 +204,7 @@ let the entry point pick it. | Directory | Purpose | Mutability | |-----------|---------|------------| | `docs/architecture/` | Conceptual descriptions of what we are building. Numbered `NN-topic.md` linearly. | Editable as the system evolves. | -| `docs/decisions/` | ADRs — one-time decisions with status, context, decision, consequences. Redirect / superseded ADRs live under `_redirects/`. | Accepted ADRs are immutable except for dated Amendments. | +| `docs/decisions/` | ADRs — one-time decisions with status, context, decision, consequences. Redirect / superseded ADRs live under `_redirects/`. | Accepted ADRs are immutable except for dated Amendments and the two bounded corrections in [Documentation Standards § Correcting and Amending ADRs](docs/standards/13-documentation.md) ([ADR-0041](docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md)). | | `docs/standards/` | Engineering rules (`NN-topic.md`, 00 – 21). Each anchored standard carries a `**Derives from:** ADR-NNNN` header. | Editable as the team learns; standard changes cite an ADR. | | `docs/roadmap/` | Phased plan (`phase-NN-topic.md`, 00 – 12 with 02a/02b/02c/**02d**, 08a/08b/08c, and 09/09b splits). Every phase doc carries the same six sections — Goal, Scope, Deliverables, Completion Criteria, Risks, Phase Exit Decision — with three declared exceptions listed in [the roadmap index](docs/roadmap/README.md): Phase 09b and Phase 12 are pointer documents into the Hub repository, and Phase 01 predates the convention. | Editable per phase; the Status block of a shipped packet is a dated delivery record and is not rewritten. | | `docs/modules/` | Per-module specifications (`/README.md` + `permissions.md` + `audit.md`), one directory per module, created with the first spec — [Tenancy](docs/modules/tenancy/README.md), Phase 02a Packet 6. The ten sections are fixed by [Documentation Standards](docs/standards/13-documentation.md). | Editable with the module. | @@ -284,8 +284,16 @@ rules: ## Things to never do -- Edit an Accepted ADR's decision section. Write a new ADR that - supersedes the old one instead. +- Edit an Accepted ADR's body, except by the two bounded mechanisms + [ADR-0041](docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md) + defines: an **inline erratum** beside the false text, which is the default, or + **in-place replacement** where the text is a canonical artifact for reuse — a + template others are told to copy, a DDL or command meant to be applied. Both + are for a statement that was false **when it entered the record**, both leave + § Status / § Date / § Deciders and all rationale untouched, and both owe a + dated Amendment **in every Accepted ADR the diff changes**. A statement that + was true then and is stale now is history: amend it, or write a new ADR that + supersedes the old one. - Introduce a fifth cross-module communication mechanism. - Add domain-specific code (CEFR, exam, English placement, kyu/dan, asana, code-challenge runner, …) to **any** module. Such shapes live diff --git a/docs/decisions/0002-initial-architecture.md b/docs/decisions/0002-initial-architecture.md index 26af1595..86b5aff9 100644 --- a/docs/decisions/0002-initial-architecture.md +++ b/docs/decisions/0002-initial-architecture.md @@ -66,6 +66,12 @@ Two backend-row clarifications recorded together because they share the trigger (do major-version + vendor calls while LearnStack is still pre-implementation, so the migration drag is zero): +> **Erratum — 2026-08-29.** Item 2 below names PostgreSQL 18's native UUIDv7 +> generator `gen_uuid_v7()`. No such function exists — it is `uuidv7()`, shown by +> `SELECT gen_uuid_v7()` on `postgres:18.4-alpine` returning `ERROR: function +> gen_uuid_v7() does not exist`. The Decision is unchanged. Current authority: [ADR-0031 +> Amendment 1](0031-postgresql-major-version.md). + 1. **Cache + state backend Redis → Valkey** per [ADR-0030](0030-redis-compatible-store-valkey.md). Redis 7.4 was the last BSD-3-Clause Redis release; the 8.x line ships under a @@ -75,7 +81,7 @@ pre-implementation, so the migration drag is zero): change — it is the RESP-provider identifier, not a vendor brand. 2. **PostgreSQL major pinned to 18.x** per [ADR-0031](0031-postgresql-major-version.md). 18 is the longest- - runway LTS available (EOL 2030-11), brings native `uuidv7()` + runway LTS available (EOL 2030-11), brings native `gen_uuid_v7()` that [ADR-0023](0023-strongly-typed-id-source-generator.md) adopts without an extension, and async I/O for sequential scans helps the partitioned `audit_log` @@ -90,3 +96,29 @@ read as "Valkey 8" or "PostgreSQL 18". Library names + protocol identifiers stay (`StackExchange.Redis`, `IConnectionMultiplexer`, `state.redis` Dapr component, RESP) — those are protocol/library identifiers, not vendor brands. + +## Amendments + +### 2026-08-29 — `gen_uuid_v7()` is not a PostgreSQL function + +Amendment 2's PostgreSQL row named the native UUIDv7 generator `gen_uuid_v7()`. +It is `uuidv7()`. Measured on `postgres:18.4-alpine`: + +```sql +SELECT gen_uuid_v7(); -- ERROR: function gen_uuid_v7() does not exist +SELECT uuidv7(); -- 01a04366-8141-753d-a6a4-161239372fd0 +``` + +The **Decision is unchanged** — PostgreSQL 18 is pinned, and generating UUIDv7 +natively without an extension is one of its reasons. Only the spelling was wrong. +The body keeps it and carries an erratum, per +[ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md): a function named +in prose is read, not applied, so it is not a canonical artifact for reuse. + +Carriers changed in this pass, so the correction is recorded rather than silent: +this ADR (1 occurrence, erratum), [ADR-0023](0023-strongly-typed-id-source-generator.md) +(6, errata), [ADR-0031](0031-postgresql-major-version.md) (5, errata), +[Backend Coding Standards § Identifiers](../standards/02-backend-coding.md) (1, +corrected), [decisions/README.md](README.md) (1, corrected) and +`LearnStack.SharedKernel/Identifiers/IGuidFactory.cs` (1, corrected). The last +three are not Accepted ADRs and immutability never bound them. diff --git a/docs/decisions/0003-tenant-isolation-defense-in-depth.md b/docs/decisions/0003-tenant-isolation-defense-in-depth.md index c8790ac0..36e999e0 100644 --- a/docs/decisions/0003-tenant-isolation-defense-in-depth.md +++ b/docs/decisions/0003-tenant-isolation-defense-in-depth.md @@ -165,20 +165,21 @@ Its binding properties: ### Which tables the template applies to -> **Amendment note (2026-08-27).** The enumeration below was written before -> `idempotency_keys` existed and before the tenant-owned class was split into -> org-scoped and tenant-wide. **[Database Standards § Table classes](../standards/05-database.md) -> is the single authority for the assignment**; this section keeps the *rule* — -> which class a table belongs to and why — and no longer keeps the list, for the -> same reason the template itself lives in one file: a list copied into three -> documents drifts in three directions, and this copy already had. +> **Erratum — 2026-08-29.** The tenant-owned row below enumerates the Phase 02a +> tenancy set. The list was correct when Amendment 3 was written on 2026-08-08 and has +> since gone stale: `idempotency_keys` did not exist yet, and the tenant-owned class +> has since been split into org-scoped and tenant-wide. It is history, not error, so +> it stands — per [ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md), a +> statement that was true when it entered the record is amended, never rewritten. +> Current authority for the assignment: [Database Standards § Table +> classes](../standards/05-database.md). Recorded in Amendment 4. The corrected template governs **tenant-owned** tables. Two classes sit outside it, and both are enumerated rather than left to judgement: | Class | Rule | Tables | |---|---|---| -| Tenant-owned | The corrected template, with the organization term present only on tables that carry an `organization_id` | every domain table, plus the Phase 02a tenancy set — enumerated and kept current in [Database Standards § Table classes](../standards/05-database.md), which splits it into org-scoped and tenant-wide | +| Tenant-owned | The corrected template | every domain table; of the Phase 02a tenancy set: `organizations`, `tenant_domains`, `tenant_locales`, `tenant_settings`, `tenant_feature_flags`, `platform_entitlement_cache`, `outbox_messages` | | Tenant-owned, self-keyed | The corrected template with the tenant term keyed on `id`, because the primary key *is* the tenant id | `tenants` | | Platform-scoped | `ENABLE` + `FORCE`, role-qualified per-command policies; the read is widened by an explicitly declared non-tenant predicate, writes stay tenant-keyed | `platform_host_to_tenant` | @@ -275,3 +276,27 @@ that sets `app.tenant_id` for the tenant being created. No migration may be written against the superseded template. +## Amendment 4 — The Phase 02a table list has gone stale (2026-08-29) + +Amendment 3's § Which tables the template applies to enumerates the Phase 02a +tenancy set in its **tenant-owned** row. The list was correct on 2026-08-08, when +Amendment 3 was written. It is not correct now: +[Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) added +`idempotency_keys`, and split the tenant-owned class into org-scoped +(`tenant_settings`, which carries an `organization_id`) and tenant-wide (the rest). + +**The row stands, with an erratum.** Under +[ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md) a statement that +was true when it entered the record and is stale now is history, not error: it is +amended, never rewritten. Packet 6 rewrote it in place, and this amendment is the +disclosure that edit owed and did not carry; the row is restored to what Amendment +3 accepted. + +**The Decision is unchanged**, and so is Amendment 3's: defense in depth by +context + query filter + RLS + architecture test, with one `AND`-ed policy per +table under `ENABLE` and `FORCE ROW LEVEL SECURITY`. Only the enumeration moved on. + +The single authority for which class a table belongs to is +[Database Standards § Table classes](../standards/05-database.md). A list copied +into three documents drifts in three directions, and this copy already had — which +is why the assignment lives in one file and this section keeps only the *rule*. diff --git a/docs/decisions/0023-strongly-typed-id-source-generator.md b/docs/decisions/0023-strongly-typed-id-source-generator.md index 7fe2fea9..7f5b55bc 100644 --- a/docs/decisions/0023-strongly-typed-id-source-generator.md +++ b/docs/decisions/0023-strongly-typed-id-source-generator.md @@ -33,8 +33,14 @@ Accepted behind an interface — so the chosen library has to be one we are comfortable depending on for the platform's lifetime, or removable with a one-shot find-and-replace if it goes unmaintained. -- **PostgreSQL 18 native `uuidv7()` is available** ([ADR-0031](0031-postgresql-major-version.md)). - The emitter has to play well with DB-side `DEFAULT uuidv7()` as well as +> **Erratum — 2026-08-29.** The driver below names PostgreSQL 18's native UUIDv7 +> generator `gen_uuid_v7()`. No such function exists — it is `uuidv7()`, shown by +> `SELECT gen_uuid_v7()` on `postgres:18.4-alpine` returning `ERROR: function +> gen_uuid_v7() does not exist`. The Decision is unchanged. Current authority: [ADR-0031 +> Amendment 1](0031-postgresql-major-version.md). + +- **PostgreSQL 18 native `gen_uuid_v7()` is available** ([ADR-0031](0031-postgresql-major-version.md)). + The emitter has to play well with DB-side `DEFAULT gen_uuid_v7()` as well as app-side `Guid.CreateVersion7()` (.NET 9+) — both code paths exist in the codebase (DB-side for high-volume audit / outbox tables; app-side for aggregates that need the ID before flush). @@ -87,9 +93,15 @@ value objects. implemented by every Vogen-emitted ID struct. The interface stays; Vogen is just the body. +> **Erratum — 2026-08-29.** The paragraph below names PostgreSQL 18's native UUIDv7 +> generator `gen_uuid_v7()`. No such function exists — it is `uuidv7()`, shown by +> `SELECT gen_uuid_v7()` on `postgres:18.4-alpine` returning `ERROR: function +> gen_uuid_v7() does not exist`. The Decision is unchanged. Current authority: [ADR-0031 +> Amendment 1](0031-postgresql-major-version.md). + The choice covers the four-artefact emission requirement, the value-object case, the PostgreSQL 18 DB-side UUIDv7 path (Vogen can wrap any `Guid`, including those minted -by `uuidv7()`), and a `[Description]`/`[ReadOnly]` annotation surface Roslyn +by `gen_uuid_v7()`), and a `[Description]`/`[ReadOnly]` annotation surface Roslyn analyzers can read for additional compile-time rules. ## Context @@ -151,7 +163,13 @@ Three things outweighed the appeal: ### What we explicitly punted on -- **UUIDv7 source.** Both DB-side (`uuidv7()`) and app-side +> **Erratum — 2026-08-29.** The bullet below names PostgreSQL 18's native UUIDv7 +> generator `gen_uuid_v7()`. No such function exists — it is `uuidv7()`, shown by +> `SELECT gen_uuid_v7()` on `postgres:18.4-alpine` returning `ERROR: function +> gen_uuid_v7() does not exist`. The Decision is unchanged. Current authority: [ADR-0031 +> Amendment 1](0031-postgresql-major-version.md). + +- **UUIDv7 source.** Both DB-side (`gen_uuid_v7()`) and app-side (`Guid.CreateVersion7()`) are valid; the choice between them is per-aggregate (high- volume insert paths like `audit_log` / `outbox_messages` prefer DB-side, aggregates that need the ID before flush prefer app-side). This stays a Standards 05 (database) @@ -232,16 +250,24 @@ Three things outweighed the appeal: `TId` carries `[ValueObject]`. Catalogued under [21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md) when the first ID lands. +> **Erratum — 2026-08-29.** The two bullets below name PostgreSQL 18's native UUIDv7 +> generator `gen_uuid_v7()`. No such function exists — it is `uuidv7()`, shown by +> `SELECT gen_uuid_v7()` on `postgres:18.4-alpine` returning `ERROR: function +> gen_uuid_v7() does not exist`. The Decision is unchanged. Current authority: [ADR-0031 +> Amendment 1](0031-postgresql-major-version.md). +> **Erratum — 2026-08-29.** The DB-side list below includes `idempotency_keys`, which +> mints no id: it is addressed by the natural key `(tenant_id, key)` and has no +> surrogate column. See Amendment 4. Current authority: [Database Standards § +> Idempotency](../standards/05-database.md). + - **UUIDv7 minting:** - - DB-side (`uuidv7()` per ADR-0031) for `audit_log`, `outbox_messages`, - `inbox_messages` — high-volume append-only tables. (`idempotency_keys` was - listed here and is not one: it is addressed by the natural key - `(tenant_id, key)` and has no surrogate id to generate. See Amendment 4.) + - DB-side (`gen_uuid_v7()` per ADR-0031) for `audit_log`, `outbox_messages`, + `inbox_messages`, `idempotency_keys` — high-volume append-only tables. - App-side (`Guid.CreateVersion7()`) for aggregates that need the ID before `SaveChangesAsync()` to emit domain events / outbox writes referencing the new aggregate ID. - **PostgreSQL 18 alignment:** Vogen-emitted `Guid` wrapper types are wire-compatible - with `uuid` columns; the `uuidv7()` `DEFAULT` is a server-side concern, + with `uuid` columns; the `gen_uuid_v7()` `DEFAULT` is a server-side concern, invisible to Vogen. ## Amendments @@ -343,8 +369,8 @@ This is a clarification; the Decision is unchanged. ### Amendment 4 — `idempotency_keys` mints no id (2026-08-27) -§ Implementation Notes listed `idempotency_keys` among the tables whose ids are -minted DB-side with `uuidv7()`. It has no id to mint. The shipped port +§ Implementation Notes lists `idempotency_keys` among the tables whose ids are +minted DB-side. It has no id to mint. The shipped port (`LearnStack.SharedKernel/Idempotency/IIdempotencyStore.cs`) addresses a record by `(tenantId, key)` at every one of its three methods, so the canonical DDL in [Database Standards § Idempotency](../standards/05-database.md) declares @@ -354,11 +380,54 @@ column nothing reads. The Decision is unchanged: UUIDv7 for `uuid` primary keys, minted app-side through `IGuidFactory` for aggregates and DB-side for the high-volume append-only tables. `idempotency_keys` is neither — it is mutable and naturally keyed — so it was never -in scope for either path. The list is corrected in place, per the precedent in -[ADR-0031 Amendment 1](0031-postgresql-major-version.md). +in scope for either path. + +The list keeps the entry and carries an erratum. An earlier draft of this amendment +removed it in place; under +[ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md) that is not +licensed — a list of table names is read, not applied, so it is not a canonical +artifact for reuse. The canonical list is the DDL in +[Database Standards § Idempotency](../standards/05-database.md). [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) creates the table. +### Amendment 5 — `gen_uuid_v7()` is not a PostgreSQL function (2026-08-29) + +Six occurrences across § Decision Drivers, § Decision, § Context and +§ Implementation Notes name PostgreSQL 18's native UUIDv7 generator +`gen_uuid_v7()`. It is `uuidv7()`. Measured on `postgres:18.4-alpine`: + +```sql +SELECT gen_uuid_v7(); -- ERROR: function gen_uuid_v7() does not exist +SELECT uuidv7(); -- 01a04366-8141-753d-a6a4-161239372fd0 +``` + +The **Decision is unchanged**: UUIDv7 is the canonical id format, wrapped by Vogen, +minted DB-side for high-volume append-only tables and app-side elsewhere. Only the +spelling of the DB-side function was wrong. The body keeps it and carries errata, +per [ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md). + +Every carrier is enumerated in +[ADR-0031 Amendment 1](0031-postgresql-major-version.md), which is where the +correction was found; this amendment is the disclosure ADR-0041 requires **in this +file**, because an amendment in another ADR discloses nothing to a reader of this +one. + +### Amendment 6 — Retroactive disclosure of the 2026-05-21 edit (2026-08-29) + +Commit `a1ad5fb` (2026-05-21, pull request #6) added `UserId` to the cross-cutting +value-object list in § Implementation Notes. This ADR was already Accepted — the +acceptance commit is `da1d37e`, 2026-05-20 — and no amendment recorded the change. +It was found by a `git log` audit during +[Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md), 99 days later. + +**The edit is not undone.** `UserId` does belong on that list, and Amendment 2 +(2026-08-10) later argued the same point at length. What was missing was the +record, which is what this amendment supplies. + +Dated the day it is written, not the day of the edit it discloses: back-dating it +to 2026-05-21 would manufacture evidence of a disclosure that never happened. + ## References - [Standards 02 § Strongly-Typed Identifiers](../standards/02-backend-coding.md) diff --git a/docs/decisions/0031-postgresql-major-version.md b/docs/decisions/0031-postgresql-major-version.md index 6ac4ef35..a12acf60 100644 --- a/docs/decisions/0031-postgresql-major-version.md +++ b/docs/decisions/0031-postgresql-major-version.md @@ -18,11 +18,17 @@ major-version choice only; the rest of ADR-0002 stands) - **Postgres 18 is the longest-runway LTS available.** EOL 2030-11 versus 16 LTS at 2028-11. Starting on 18 buys an extra two years of upstream patches before any forced major upgrade. -- **`uuidv7()` is native in 18.** LearnStack's +> **Erratum — 2026-08-29.** The driver below names PostgreSQL 18's native UUIDv7 +> generator `gen_uuid_v7()`. No such function exists — it is `uuidv7()`, shown by +> `SELECT gen_uuid_v7()` on `postgres:18.4-alpine` returning `ERROR: function +> gen_uuid_v7() does not exist`. The Decision is unchanged. Current authority: [ADR-0031 +> Amendment 1](#amendments). + +- **`gen_uuid_v7()` is native in 18.** LearnStack's [ADR-0023 (Strongly-typed ID source generator)](0023-strongly-typed-id-source-generator.md) adopts UUIDv7 as the canonical id format (time-ordered, index-friendly). Postgres 18 ships a built-in - `uuidv7()` SQL function — DB-side DEFAULT values become trivial, + `gen_uuid_v7()` SQL function — DB-side DEFAULT values become trivial, the app side keeps the strongly-typed wrapping, no extension is required. Postgres 16 / 17 force a choice between an extension (`pg_uuidv7`) and app-side generation; Postgres 18 closes that gap @@ -95,9 +101,15 @@ extension we picked early diverges from the 18 native function. ### What 18 brings that we directly benefit from +> **Erratum — 2026-08-29.** The table row below names PostgreSQL 18's native UUIDv7 +> generator `gen_uuid_v7()`. No such function exists — it is `uuidv7()`, shown by +> `SELECT gen_uuid_v7()` on `postgres:18.4-alpine` returning `ERROR: function +> gen_uuid_v7() does not exist`. The Decision is unchanged. Current authority: [ADR-0031 +> Amendment 1](#amendments). + | 18 feature | LearnStack benefit | |------------|--------------------| -| `uuidv7()` built-in | ADR-0023 uses DB-side `DEFAULT uuidv7()` for high-volume append-only tables without committing to an extension | +| `gen_uuid_v7()` built-in | ADR-0023 uses DB-side `DEFAULT gen_uuid_v7()` for high-volume append-only tables without committing to an extension | | Async I/O for sequential scans | `audit_log` partition scans (ADR-0016) — operator query latency | | OAuth authentication | Optional shortcut for Phase 11 break-glass paths (not adopted today) | | Virtual generated columns | Computed columns for `LocalizedMessage`-like derived data (Phase 02a+) | @@ -167,8 +179,14 @@ preview, that preview already runs on 18 — no major upgrade needed. - **This commit** (Phase 01 packet 6 cleanup): dev compose image bump; ADR-0002 Amendment 2 references this decision; doc sweep across Standards 12 / Architecture / Standards 20. +> **Erratum — 2026-08-29.** The phase note below names PostgreSQL 18's native UUIDv7 +> generator `gen_uuid_v7()`. No such function exists — it is `uuidv7()`, shown by +> `SELECT gen_uuid_v7()` on `postgres:18.4-alpine` returning `ERROR: function +> gen_uuid_v7() does not exist`. The Decision is unchanged. Current authority: [ADR-0031 +> Amendment 1](#amendments). + - **Phase 02a** (Platform kernel): first EF migration targets Postgres - 18; ADR-0023 adopts UUIDv7 with DB-side `uuidv7()` as the + 18; ADR-0023 adopts UUIDv7 with DB-side `gen_uuid_v7()` as the default-value generator for high-volume append-only tables. - **Phase 11** (production hardening): production sizing, backup cadence, replication topology — all written for 18. @@ -192,30 +210,36 @@ wrong, and it was wrong in the one place a spelling matters: a `DEFAULT` clause in a migration. [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) writes the first such clause, which is why this surfaced now. -The name is corrected at every carrier rather than annotated at each one, -because one of the carriers is C# — `IGuidFactory.cs`'s XML remarks — and a -Markdown erratum cannot sit beside it. - -This ADR previously cited +**The three ADR bodies keep the wrong name and carry an erratum; the three +non-ADR carriers are simply corrected.** That split is +[ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md)'s: in-place +replacement is licensed only where the text is a canonical artifact for reuse — a +template others are told to copy, a DDL or command meant to be applied — and a +function named in prose is read, not applied. The first draft of this amendment +swept all six, and cited [ADR-0003 Amendment 3](0003-tenant-isolation-defense-in-depth.md) as precedent for "wrong content inside an Accepted ADR corrected in place". **That citation -was false**, and git says so: the RLS template it removed sat at line 53 of the -pre-amendment file, inside `## Amendment 1 — Organization scope (2026-05-18)`. +was false**, and git says so: the RLS template ADR-0003 removed sat at line 53 of +the pre-amendment file, inside `## Amendment 1 — Organization scope (2026-05-18)`. ADR-0003's `## Decision` block has never been edited, and the ADR has no section named "Decision outcome". An amendment corrected an amendment; no accepted Decision body was touched. -[ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md) is where the -instruments are told apart. The carriers, so the edit is recorded rather than -silent: - -| Carrier | Occurrences | -|---|---| -| This ADR — § Decision Drivers, § Context, § Implementation Notes, § References | 5 | -| [ADR-0023](0023-strongly-typed-id-source-generator.md) — § Decision Drivers, § Decision, § Context, § Implementation Notes | 6 | -| [ADR-0002](0002-initial-architecture.md) — the PostgreSQL row | 1 | -| [Backend Coding Standards § Identifiers](../standards/02-backend-coding.md) | 1 | -| [decisions/README.md](README.md) — this ADR's summary row | 1 | -| `LearnStack.SharedKernel/Identifiers/IGuidFactory.cs` — XML remarks | 1 | + +That `IGuidFactory.cs` cannot hold a Markdown erratum is an argument for +correcting `IGuidFactory.cs`, which is code and which immutability never bound. +It is not a licence to rewrite an ADR alongside it: each carrier is judged on its +own. + +The carriers, so the correction is recorded rather than silent: + +| Carrier | Occurrences | Instrument | +|---|---|---| +| This ADR — § Decision Drivers, § Context, § Implementation Notes, § References | 5 | erratum | +| [ADR-0023](0023-strongly-typed-id-source-generator.md) — § Decision Drivers, § Decision, § Context, § Implementation Notes | 6 | erratum, disclosed in its own Amendment | +| [ADR-0002](0002-initial-architecture.md) — Amendment 2's PostgreSQL row | 1 | erratum, disclosed in its own Amendment | +| [Backend Coding Standards § Identifiers](../standards/02-backend-coding.md) | 1 | corrected | +| [decisions/README.md](README.md) — this ADR's summary row | 1 | corrected | +| `LearnStack.SharedKernel/Identifiers/IGuidFactory.cs` — XML remarks | 1 | corrected | `gen_random_uuid()` is a real function and remains correct where it appears; it produces a **v4** UUID, which is what [ADR-0023](0023-strongly-typed-id-source-generator.md) @@ -228,7 +252,7 @@ LearnStack table therefore defaults to `uuidv7()` or is minted app-side through - [ADR-0002 Initial Architecture](0002-initial-architecture.md) — original PostgreSQL major-version row, now partially superseded. - [ADR-0003 Tenant Isolation Defense in Depth](0003-tenant-isolation-defense-in-depth.md) — RLS pattern unchanged across 16/17/18. - [ADR-0016 Audit Log Subsystem](0016-audit-log-subsystem.md) — partitioned `audit_log` benefits from async I/O. -- [ADR-0023 Strongly-typed ID source generator](0023-strongly-typed-id-source-generator.md) — adopts UUIDv7; PostgreSQL 18's native `uuidv7()` powers the DB-side default path. +- [ADR-0023 Strongly-typed ID source generator](0023-strongly-typed-id-source-generator.md) — adopts UUIDv7; PostgreSQL 18's native `gen_uuid_v7()` powers the DB-side default path. **Erratum 2026-08-29:** the function is `uuidv7()`; see Amendment 1. - [Standards 05 — Database](../standards/05-database.md) - [Standards 12 § Database Operations](../standards/12-infrastructure.md) - PostgreSQL 18 release notes: . diff --git a/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md b/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md index 4fdc5096..56a94456 100644 --- a/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md +++ b/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md @@ -2,9 +2,10 @@ ## Status -Proposed +Accepted **Date:** 2026-08-28 **Deciders:** @platform +**Accepted:** 2026-08-29 ## Decision Drivers @@ -290,8 +291,11 @@ Named so the cost is visible before the decision, not after: ## Implementation Notes -Accepting this ADR is one commit that touches **seventeen files** — the sixteen -below plus this one, whose Status flips to Accepted. The rule is stated in +Accepting this ADR is one commit that touches **eighteen files** — the seventeen +below plus this one, whose Status flips to Accepted. The eighteenth is +[ADR-0003](0003-tenant-isolation-defense-in-depth.md), which the enforcement check +below found the moment it was written: Packet 6 had rewritten a table row inside +Amendment 3 with an ad-hoc note and no dated Amendment. The rule is stated in seventeen sentences across thirteen tracked files; the first draft named three of them, and counted the reversals in § Consequences without listing them. @@ -329,6 +333,7 @@ them, and counted the reversals in § Consequences without listing them. | [ADR-0023](0023-strongly-typed-id-source-generator.md) | same, plus Amendment 4's in-place list edit becomes an erratum | | [ADR-0002](0002-initial-architecture.md) | same, for its one occurrence — the carrier ADR-0031's table calls "the PostgreSQL row" | | [.github/workflows/ci.yml](../../.github/workflows/ci.yml) | the disclosure check described under **Enforcement** | +| [ADR-0003](0003-tenant-isolation-defense-in-depth.md) | Amendment 3's tenant-owned table row is restored and carries an erratum, disclosed by a new Amendment 4. The list was **true when Amendment 3 was written** and went stale when Packet 6 added `idempotency_keys` and split the class — so it is history, and replacing it was never licensed. Found by the check, not by review | **A retroactive amendment, owed to the record rather than to this rule:** [ADR-0023](0023-strongly-typed-id-source-generator.md) gains an amendment dated diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 4c1c6db0..c47e6c34 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -9,7 +9,9 @@ This directory contains LearnStack ADRs. Each ADR captures a one-time decision w - **Superseded** — Replaced by a newer ADR. Kept as a redirect. - **Deprecated** — No longer applies. Kept for history. -Accepted ADRs are not rewritten. A new decision is a new ADR, possibly superseding the old one. Dated Amendments, appended at the bottom, are the one sanctioned way to add to an accepted record — see [Documentation Standards § ADR Amendments](../standards/13-documentation.md). Whether an Accepted ADR's **body** may ever be corrected, and with which instrument, is the open question in [ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md). +Accepted ADRs are not rewritten. A new decision is a new ADR, possibly superseding the old one. Dated Amendments, appended at the bottom, are how an accepted record is added to. + +A body that says something **false** is the one exception, and it is bounded by [ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md): an **inline erratum** beside the text is the default, **in-place replacement** only where the text is a canonical artifact for reuse, both only for a statement false when it entered the record, and both owing a dated Amendment in every Accepted ADR the diff changes. The operating rule is [Documentation Standards § Correcting and Amending ADRs](../standards/13-documentation.md). ## Active ADRs @@ -52,7 +54,7 @@ Accepted ADRs are not rewritten. A new decision is a new ADR, possibly supersedi | 0038 | [Cross-Cutting Port and Event Contracts](0038-cross-cutting-port-and-event-contracts.md) | **Supersedes ADR-0014.** Retains Dapr behind demand gates; fixes the event envelope, handler isolation, trace/audit scope and cache contracts | | 0039 | [The Optimistic Concurrency Token](0039-optimistic-concurrency-token.md) | An explicit `row_version bigint` (CLR `long`), project-wide, incremented inside `AuditableEntity` by the single primitive `MarkUpdated` and `SoftDelete` both route through, and mapped with `HasDefaultValue(0L).IsConcurrencyToken().ValueGeneratedNever()` — exactly those three calls (Amendment 2): `ValueGeneratedOnAddOrUpdate()` / `IsRowVersion()` make EF omit the column from the `UPDATE` entirely (Amendment 1, measured), and `HasDefaultValue` alone leaves `ValueGenerated` at `OnAdd`, which the registered rule rejects; `xmin` is rejected because the token is client-visible through ETag / `If-Match` and a dump-restore or logical-replication cutover changes it — measured, unlike the widely-cited `VACUUM FREEZE` objection, which does not | | 0040 | [The Ambient Unit of Work](0040-ambient-unit-of-work.md) | One `DbConnection` per scope, owned by `IUnitOfWork`; every module `DbContext`, `IAuditStore` and `IOutbox` enlists on it, because `SET LOCAL` is connection-local and a context on its own connection reads **zero rows** under the corrected RLS policy. Spans one aggregate's write plus audit plus outbox plus any reads — cross-aggregate writes stay forbidden. Defines nesting, disposal, and the event-consumer entry point that never reaches MediatR | -| 0041 | [Correcting False Statements in Accepted ADRs](0041-correcting-false-statements-in-accepted-adrs.md) | **Proposed — awaiting decision.** Inline erratum beside the false text is the default; in-place replacement only where the text is a **canonical artifact for reuse** — a template others are told to copy, a DDL or command meant to be applied — never merely because something could be copied, and never because a non-ADR carrier holds the same token. Bounded to statements false **when they entered the record**, judged per statement: original body at the acceptance commit, amendment text at that amendment's date. A statement that has since gone stale is history, and gets an amendment or a superseding ADR. Every changed Accepted ADR carries its own dated Amendment | +| 0041 | [Correcting False Statements in Accepted ADRs](0041-correcting-false-statements-in-accepted-adrs.md) | Inline erratum beside the false text is the default; in-place replacement only where the text is a **canonical artifact for reuse** — a template others are told to copy, a DDL or command meant to be applied — never merely because something could be copied, and never because a non-ADR carrier holds the same token. Bounded to statements false **when they entered the record**, judged per statement: original body at the acceptance commit, amendment text at that amendment's date. A statement that has since gone stale is history, and gets an amendment or a superseding ADR. Every changed Accepted ADR carries its own dated Amendment | ## Superseded ADRs diff --git a/docs/decisions/template.md b/docs/decisions/template.md index 8f4fe5e7..f853df4a 100644 --- a/docs/decisions/template.md +++ b/docs/decisions/template.md @@ -74,12 +74,20 @@ years figure out whether circumstances have changed enough to re-open the ADR. ## Amendments Dated, append-only clarifications that do not change the Decision section. If the -decision itself changes, write a new ADR that supersedes this one. +decision itself changes, write a new ADR that supersedes this one. Omit this whole +section until there is an amendment to put in it. ### YYYY-MM-DD — Clarification title …short note about what was previously ambiguous and how it should be read now. +An amendment that records a **correction** names three things, per +[ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md): what was wrong, +**how it was shown wrong** (the command, the query, the file), and **every carrier +changed** — and it restates the Decision as unchanged. Where the correction is an +enumeration, it also names the document the corpus treats as canonical for that +list. + ## References + policies, so they need the migration assembly and the persistence model + directly — not the DI registration. AddLearnStackPersistence does register + TenancyDbContext through AddModuleDbContext, but a registration builds a + context on the ambient connection at request time; it does not give a test + the Migrations folder to apply. --> diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs index fc4d7a34..8570dba7 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs @@ -489,6 +489,22 @@ public void A_locale_is_bounded_by_the_length_its_column_holds(string locale, bo } } + [Theory] + // Case is not significant in BCP-47, which is the problem for a column that is + // half the primary key: without canonicalization `en-US` and `en-us` are two + // rows naming one locale, for one tenant. + [InlineData("en-us", "en-US")] + [InlineData("EN-US", "en-US")] + [InlineData("tr-tr", "tr-TR")] + [InlineData("ZH-hans-cn", "zh-Hans-CN")] + [InlineData("en-US", "en-US")] + [InlineData("tr", "tr")] + public void A_locale_is_stored_in_canonical_case(string input, string expected) + { + TenantLocale.Create(Tenant, input, isDefault: true) + .Locale.Should().Be(expected); + } + [Theory] [InlineData("tr")] [InlineData("tr-TR")] diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 0bb8e151..edc7d19f 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -160,7 +160,8 @@ CREATE INDEX ix_courses_tenant_id_organization_id ON courses (tenant_id, organiz PostgreSQL evaluates referential integrity at **runtime** as a security-restricted operation on behalf of the table owner, and those RI triggers are **not subject to Row Level Security**. (The DDL path is the opposite and matters in migrations: the scan -`ALTER TABLE … ADD CONSTRAINT` / `VALIDATE CONSTRAINT` performs runs as the issuing role +`ALTER TABLE … ADD CONSTRAINT` / `VALIDATE CONSTRAINT` performs runs as the +issuing role under its policies, so a constraint added to a populated tenant-owned table validates against the rows that role can see. § Data Migrations carries the consequence.) A single-column `lessons.course_id → courses.id` therefore lets a row in tenant A diff --git a/docs/standards/13-documentation.md b/docs/standards/13-documentation.md index 3518f664..4a08e400 100644 --- a/docs/standards/13-documentation.md +++ b/docs/standards/13-documentation.md @@ -226,7 +226,7 @@ An Accepted ADR sometimes carries a statement that was **false when it entered t 2. The text is a **canonical artifact for reuse** — a template other documents are told to copy, a DDL or config block meant to be applied, a command meant to be run. Not merely something that *could* be copied: an illustrative sketch is read, not applied, and gets an erratum. A carrier outside the ADRs licenses nothing; correct that carrier on its own. 3. The diff adds and removes no normative content — no obligation, scope, alternative, rationale or consequence. -**Never touched by either mechanism:** § Status, § Date, § Deciders, and all rationale, framing, trade-offs and judgements. A Status change is a lifecycle event, not a fact correction. +**Never touched by either mechanism:** § Status and the `**Date:**` / `**Deciders:**` fields beneath it, and all rationale, framing, trade-offs and judgements. A Status change is a lifecycle event, not a fact correction. **Both mechanisms owe the same three things:** diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 5e655254..c9a1fac7 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -93,11 +93,11 @@ not implemented is the failure mode this column exists to prevent. ### Implemented today -Twenty-nine test methods exist in +Thirty-six test methods exist in [`backend/tests/LearnStack.Tests.Architecture`](../../backend/tests/LearnStack.Tests.Architecture), shipped by [Phase 01](../roadmap/phase-01-repository-tooling.md), [Phase 02a Packets 2–3](../roadmap/phase-02a-kernel-tenancy.md), Packet 4 and -Packet 6 — 43 cases once the theories expand. Methods are not rows: a `[Theory]` +Packet 6 — 55 cases once the theories expand. Methods are not rows: a `[Theory]` is one row and many cases, and several rows pair a rule with the companion assertion that stops it passing vacuously. @@ -134,6 +134,8 @@ against a host serving unversioned endpoints. | `Handlers_Return_Result` | `CrossCuttingFoundationTests.cs` | | `Modules_Do_Not_Reference_DeploymentMode` | `CrossCuttingFoundationTests.cs` | | `IErrorTrackingProvider_Is_Singleton` | `CrossCuttingFoundationTests.cs` | +| `Modules_Do_Not_Inject_IEventBus_Directly` | `CrossCuttingFoundationTests.cs` | +| `Integration_Event_TopicNames_FollowConvention` | `CrossCuttingFoundationTests.cs` | | `ModuleDomain_DoesNotDependOn_OtherModuleDomain` (per-module theory) | `ModuleDependencyTests.cs` | | `ModuleDomain_DoesNotDependOn_AnyApplicationOrInfrastructure` (per-module theory) | `ModuleDependencyTests.cs` | | `Meta_NetArchTest_DetectsAPlantedViolation` | `ModuleDependencyTests.cs` | @@ -148,6 +150,12 @@ against a host serving unversioned endpoints. | `Organization_Aggregate_Declared_In_Tenancy_Domain` (per-type theory) | `TenancyConventionTests.cs` | | `Aggregates_With_Optimistic_Concurrency_Map_RowVersion` | `PersistenceConventionTests.cs` | | `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork` | `PersistenceConventionTests.cs` | +| `The_registration_marker_does_not_vouch_across_containers` | `PersistenceConventionTests.cs` | +| `Every_Database_Test_Carries_The_Docker_Trait` | `PersistenceConventionTests.cs` | +| `Migrate_Target_Refuses_An_Aliased_Runtime_Credential` (per-alias theory) | `PersistenceConventionTests.cs` | +| `Migrate_Target_Redacts_A_Quoted_Value_Whole` (per-shape theory) | `PersistenceConventionTests.cs` | +| `Migrate_Target_Reads_The_Role_Through_A_Quoted_Value` | `PersistenceConventionTests.cs` | +| `Migrate_Target_Refuses_A_Uri_Without_Echoing_Its_Userinfo` | `PersistenceConventionTests.cs` | | `TransactionBehavior_Does_Not_Reference_A_Module_Assembly` | `PersistenceConventionTests.cs` | | `Migration_Startup_Project_References_EntityFrameworkCore_Design` | `PersistenceConventionTests.cs` | | `Migrate_Target_Covers_Every_Migration_Chain` | `PersistenceConventionTests.cs` | diff --git a/infra/compose/README.md b/infra/compose/README.md index 923bf675..a6a1dd5c 100644 --- a/infra/compose/README.md +++ b/infra/compose/README.md @@ -52,7 +52,7 @@ tell you: init scripts do not re-run, `make dev` reports healthy, and the first volume) and `make dev`, or apply the script by hand: ```bash -docker compose --env-file .env -f infra/compose/dev.yml exec -T postgres psql -v ON_ERROR_STOP=1 -U learnstack -d learnstack < infra/compose/postgres-init/02-create-roles.sql +docker compose --env-file .env -f infra/compose/dev.yml exec -T postgres sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < infra/compose/postgres-init/02-create-roles.sql ``` It is idempotent, so re-running it against a cluster that already has the roles From 71e74bb301034793a43fb3e0c9c1882ed07d24d6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 29 Aug 2026 17:34:50 +0300 Subject: [PATCH 34/35] fix(decisions): let a fingerprint mismatch survive the reclaim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0037's claim statement gated its reclaim on `expires_at` alone. An expired lease met by a DIFFERENT request therefore overwrote the stored fingerprint, and RETURNING handed the caller back its own — so the caller could not detect the mismatch and answered Acquired. The ADR's own outcome table says Mismatched wins over every row in it, that one included, and a changed request could take over a key while the original attempt may still have been running. Measured on postgres:18.4-alpine. With expiry alone the reclaim returned FINGERPRINT-B against a row holding FINGERPRINT-A; with the fingerprint term added it returns FINGERPRINT-A, and a matching fingerprint still reclaims with the new claim token. `fingerprint` is now assigned its own stored value rather than dropped from the SET list: DO UPDATE must assign something, and assigning the stored value is what carries the mismatch into RETURNING. Amendment 2 is unmerged on this branch, so this edits a draft. ADR: 0037 Co-Authored-By: Claude Opus 5 (1M context) --- .../0037-idempotency-key-contract.md | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/docs/decisions/0037-idempotency-key-contract.md b/docs/decisions/0037-idempotency-key-contract.md index 55a32263..282d2573 100644 --- a/docs/decisions/0037-idempotency-key-contract.md +++ b/docs/decisions/0037-idempotency-key-contract.md @@ -464,25 +464,43 @@ INSERT INTO idempotency_keys VALUES (@tenant, @key, @fingerprint, @token, 'in_flight', now() + interval '5 minutes') ON CONFLICT (tenant_id, key) DO UPDATE SET -- Fires unconditionally so RETURNING always has a row; the expiry test moves - -- into the SET expressions. - fingerprint = CASE WHEN idempotency_keys.expires_at <= now() THEN EXCLUDED.fingerprint ELSE idempotency_keys.fingerprint END, - claim_token = CASE WHEN idempotency_keys.expires_at <= now() THEN EXCLUDED.claim_token ELSE idempotency_keys.claim_token END, - state = CASE WHEN idempotency_keys.expires_at <= now() THEN 'in_flight' ELSE idempotency_keys.state END, - expires_at = CASE WHEN idempotency_keys.expires_at <= now() THEN EXCLUDED.expires_at ELSE idempotency_keys.expires_at END, + -- into the SET expressions. `reclaimable` is expiry AND fingerprint equality, + -- repeated because a SET expression cannot see a name bound elsewhere in the + -- same statement. + -- + -- The fingerprint term is not optional. With expiry alone, an expired lease + -- met by a DIFFERENT request overwrote the stored fingerprint and RETURNING + -- handed the caller back its own — so the caller could not detect the + -- mismatch, and a changed request took over a key while the original attempt + -- may still have been running. Measured on postgres:18.4-alpine: the reclaim + -- returned `FINGERPRINT-B` where the row held `FINGERPRINT-A`, and the outcome + -- table below says `Mismatched` wins over every row in it, this one included. + fingerprint = idempotency_keys.fingerprint, + claim_token = CASE WHEN idempotency_keys.expires_at <= now() AND idempotency_keys.fingerprint = EXCLUDED.fingerprint THEN EXCLUDED.claim_token ELSE idempotency_keys.claim_token END, + state = CASE WHEN idempotency_keys.expires_at <= now() AND idempotency_keys.fingerprint = EXCLUDED.fingerprint THEN 'in_flight' ELSE idempotency_keys.state END, + expires_at = CASE WHEN idempotency_keys.expires_at <= now() AND idempotency_keys.fingerprint = EXCLUDED.fingerprint THEN EXCLUDED.expires_at ELSE idempotency_keys.expires_at END, -- The re-acquire branch MUST clear the previous outcome. Measured: without -- these four the new claim inherits the expired row's status_code and a later -- replay answers with a response this request never produced. - status_code = CASE WHEN idempotency_keys.expires_at <= now() THEN NULL ELSE idempotency_keys.status_code END, - content_type = CASE WHEN idempotency_keys.expires_at <= now() THEN NULL ELSE idempotency_keys.content_type END, - headers = CASE WHEN idempotency_keys.expires_at <= now() THEN NULL ELSE idempotency_keys.headers END, - body = CASE WHEN idempotency_keys.expires_at <= now() THEN NULL ELSE idempotency_keys.body END, + status_code = CASE WHEN idempotency_keys.expires_at <= now() AND idempotency_keys.fingerprint = EXCLUDED.fingerprint THEN NULL ELSE idempotency_keys.status_code END, + content_type = CASE WHEN idempotency_keys.expires_at <= now() AND idempotency_keys.fingerprint = EXCLUDED.fingerprint THEN NULL ELSE idempotency_keys.content_type END, + headers = CASE WHEN idempotency_keys.expires_at <= now() AND idempotency_keys.fingerprint = EXCLUDED.fingerprint THEN NULL ELSE idempotency_keys.headers END, + body = CASE WHEN idempotency_keys.expires_at <= now() AND idempotency_keys.fingerprint = EXCLUDED.fingerprint THEN NULL ELSE idempotency_keys.body END, -- Amendment 3: without this the reclaimed row reports a new fence against the -- timestamp of a claim several reclaims ago. - claimed_at = CASE WHEN idempotency_keys.expires_at <= now() THEN EXCLUDED.claimed_at ELSE idempotency_keys.claimed_at END + claimed_at = CASE WHEN idempotency_keys.expires_at <= now() AND idempotency_keys.fingerprint = EXCLUDED.fingerprint THEN EXCLUDED.claimed_at ELSE idempotency_keys.claimed_at END RETURNING (xmax = 0) AS inserted, state, fingerprint, claim_token, status_code, content_type, headers, body; ``` +`fingerprint` is assigned its own stored value rather than left out of the `SET` +list: a `DO UPDATE` must assign something, and assigning the stored value is what +makes the mismatch survive into `RETURNING`. An expired row whose fingerprint +differs is therefore untouched by the reclaim — same token, same state, same +outcome columns — and the caller reads the stored fingerprint and answers +`Mismatched`. An expired row whose fingerprint matches reclaims exactly as before; +both halves measured. + **The deciding column is `claim_token`, not `state`.** `xmax = 0` separates a fresh insert from a conflict resolution, but `state` and `fingerprint` alone cannot separate the two conflict outcomes that matter most: a claim blocked by a live lease From d3b43e748a1b2d3e9c8bdc11f9b3a72af10c26c4 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 29 Aug 2026 17:38:29 +0300 Subject: [PATCH 35/35] docs(ci): record the required-check name the live rule still names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `main`'s protection requires `meta (commit hygiene + link audit)`. The job was renamed to `meta (compose + commit hygiene + link audit)` in 2998e12, which is already on main, and GitHub matches required checks by name — so the required check never reports and every pull request sits at "Expected — waiting for status to be reported", green everywhere else. CONTRIBUTING already warned that a rename is the dangerous half of activating a check. It warned about the direction that waves a PR through; this is the other direction, and it blocks. Both outstanding edits are now named together: this one, and `backend integration (Testcontainers)`, which runs on every PR and is required by nothing. Both are repository settings, not files in this repo. Co-Authored-By: Claude Opus 5 (1M context) --- .github/CONTRIBUTING.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index fac44650..e0f69500 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -24,6 +24,11 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule > Everything else in this section — required status checks, linear history, no > force pushes, no direct pushes — is live today. Only the two named settings > are deferred. +> +> **Two required-check edits are outstanding**, both flagged in the list below: +> the `meta` check is required under a name nothing reports any more, and +> `backend integration (Testcontainers)` runs on every pull request and is not +> required at all. The first blocks every merge; the second gates nothing. - **Require a pull request before merging** - Require approvals: **1** (raise to 2 once the team grows past two @@ -37,6 +42,13 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule - `frontend (typecheck + lint + build + test)` - `meta (compose + commit hygiene + link audit)` - `secret scan (leakwatch)` + - `meta (compose + commit hygiene + link audit)` — ⚠️ **the live rule still + requires the pre-rename name** `meta (commit hygiene + link audit)`, which + nothing reports. GitHub matches by name, so that required check never + arrives and **every** pull request sits at "Expected — waiting for status to + be reported". Re-require it under the current name; the job itself is green. + This is the failure the warning below describes, in the direction that + blocks rather than the one that waves through. - `backend integration (Testcontainers)` — **activated in Phase 02a Packet 6** with the four-role provisioning suite, one packet earlier than planned: Packet 6 ships the first Docker-bound test and is therefore the packet that