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 1ed3a341..d9222b94 100644 --- a/.claude/skills/add-backend-module/SKILL.md +++ b/.claude/skills/add-backend-module/SKILL.md @@ -95,17 +95,23 @@ 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) { - services.AddDbContext<DbContext>(opt => opt - .UseNpgsql(configuration.GetConnectionString("Default")) - .UseSnakeCaseNamingConvention()); - + // 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); @@ -131,6 +137,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`: @@ -144,9 +169,11 @@ 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 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); } } @@ -157,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 @@ -184,8 +210,16 @@ 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 -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..8b8c5c84 100644 --- a/.claude/skills/add-ef-migration/SKILL.md +++ b/.claude/skills/add-ef-migration/SKILL.md @@ -51,7 +51,19 @@ and survive forward-only deploy rules ### Step 1: Generate the migration ```bash -dotnet ef migrations add _ \ +# 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/['\"]$//") + +# 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 @@ -69,7 +81,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 @@ -83,10 +95,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) @@ -283,12 +302,34 @@ 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 "$ConnectionStrings__Migration" ``` +`ConnectionStrings__Migration` is the environment spelling of +`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 migration is consistent. @@ -311,8 +352,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 93177cda..f7178522 100644 --- a/.claude/skills/add-integration-test/SKILL.md +++ b/.claude/skills/add-integration-test/SKILL.md @@ -2,9 +2,14 @@ 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. 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: pure unit tests (use `LearnStack.Tests.Unit`), architecture tests (use @@ -45,41 +50,78 @@ 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 ### Step 1: Reuse the fixture -The project ships `TestFixture` that: - -- Spins Postgres + Valkey (+ optional Kafka via Dapr) via Testcontainers. -- Applies all migrations (per module). -- Seeds a baseline platform admin, two tenants, two orgs per tenant. -- Exposes `fixture.AsTenant(tenantId, organizationId?)` to scope a block. +> **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 touching either carries `[Trait(RequiresDocker.Key, RequiresDocker.Value)]`, +> which is how CI routes it to `backend-integration` rather than `backend`. + +What the fixtures do today: + +- 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). +- 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 +[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. } } ``` @@ -88,62 +130,85 @@ 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 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. + For a handler that publishes an integration event: ```csharp @@ -244,12 +309,14 @@ 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. - **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/add-mediatr-handler/SKILL.md b/.claude/skills/add-mediatr-handler/SKILL.md index c2772871..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 @@ -143,17 +148,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 0ea7b6fe..078990b7 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 @@ -45,7 +59,7 @@ cross-tenant leak; this skill is the prevention. | 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 @@ -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 @@ -128,19 +144,61 @@ 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 -`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. +**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** — 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 -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 ``` @@ -163,10 +221,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. @@ -252,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 @@ -310,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/commit-and-pr/SKILL.md b/.claude/skills/commit-and-pr/SKILL.md index c9414e91..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 @@ -81,7 +84,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/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index 4f34c008..e7d7a528 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -98,11 +98,61 @@ 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 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. 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" "$default_cs" +``` + +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 +([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/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index 244aa2eb..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 / E2E | Required for Testcontainers + LiveKit. | +| 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" ``` @@ -60,10 +62,15 @@ 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. - -frontend/apps/web/ # Vitest + axe-core + Playwright (E2E). + 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. 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 @@ -103,8 +110,13 @@ Common failure messages and fixes: ### Step 5: Run integration tests -Testcontainers spin Postgres + Valkey per class fixture; runtime depends on Docker -performance. +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 \ @@ -120,31 +132,26 @@ 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. | +| 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 ```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 @@ -168,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 @@ -193,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 \ @@ -213,24 +213,28 @@ 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. +- 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 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 - [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/.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/standards-check/SKILL.md b/.claude/skills/standards-check/SKILL.md index 47b218af..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. @@ -214,8 +218,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/.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/.claude/skills/write-adr/SKILL.md b/.claude/skills/write-adr/SKILL.md index d2ad2350..5934c8e9 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. @@ -118,13 +125,15 @@ present tense.> - Prior-art (Nexora paths if applicable — see [13-documentation.md § Local-Only Directories](../../../docs/standards/13-documentation.md) for the rule against `docs/analysis/` references). - -## Amendments - - ``` +**No `## Amendments` heading at authoring time.** The corpus convention is a +biconditional — every ADR with the heading has at least one entry, and every ADR +without amendments omits it entirely. Append the section with the first dated +clarification, which must not change the Decision section; one recording a +correction names what was wrong, how it was shown wrong, and every carrier +changed. + ### Step 3: Cross-link After writing the ADR: @@ -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/.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..e78cf793 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/.githooks/pre-commit b/.githooks/pre-commit index 9e50f173..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 @@ -208,7 +209,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 +236,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 55235383..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 @@ -35,8 +40,23 @@ 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)` + - `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 + 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 +65,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 @@ -95,9 +113,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 @@ -118,23 +142,29 @@ 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/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 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 @@ -150,5 +180,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 363fd889..651fa279 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,17 +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 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. # - 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,21 +102,29 @@ 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 \ - --logger "trx;LogFileName=test-results.trx" \ + --filter "Requires!=Docker" \ + --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 @@ -119,16 +133,67 @@ 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 — 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, 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: - - run: echo "Placeholder — Phase 02a wires the first Testcontainers integration test." + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + # 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: + dotnet-version: ${{ env.DOTNET_SDK_VERSION }} + + - name: Restore + working-directory: backend + run: dotnet restore LearnStack.slnx + + - 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 \ + --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: @@ -229,6 +294,59 @@ 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 + + # 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 + 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 < "${RUNNER_TEMP}/subjects" + 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, @@ -255,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. @@ -285,13 +419,84 @@ 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." 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 @@ -345,7 +550,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 8ef9c953..5f663472 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,8 +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 -after a four-report audit of the corpus.** +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 +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 @@ -88,6 +91,42 @@ 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. + +**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: @@ -114,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 @@ -162,9 +204,10 @@ 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. | | `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 @@ -241,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/Makefile b/Makefile index cdaa1920..f639f739 100644 --- a/Makefile +++ b/Makefile @@ -117,6 +117,93 @@ build-backend: ## `dotnet build` the solution. build-frontend: ## `pnpm -r build` the frontend monorepo. (cd frontend && pnpm -r build) +.PHONY: migrate +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 + @# 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. + @# + @# `--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: + @# + @# 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. + @# 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 \ + | tail -1 | tr -d "\r" | sed "s/^['\"]//; s/['\"]$$//"); \ + fi; \ + 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"; \ + 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. 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"; \ + dotnet tool restore >/dev/null; \ + found=0; \ + failed=0; \ + 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)"; \ + dotnet ef database update \ + --project "$$proj" \ + --startup-project backend/src/LearnStack.Api \ + --connection "$$migration_cs" || failed=1; \ + done; \ + if [ "$$found" = "0" ]; then \ + echo "No project carries Persistence/Migrations yet — the first lands with the Tenancy schema in Phase 02a Packet 6."; \ + fi; \ + exit "$$failed" + .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 @@ -129,15 +216,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/README.md b/README.md index 3d6bce3d..311ff7f1 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, 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 @@ -58,6 +60,25 @@ 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. +[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 MUST-class audit in [ADR-0033](docs/decisions/0033-audit-durability-model.md)), moved @@ -65,11 +86,13 @@ 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 -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/.editorconfig b/backend/.editorconfig index 0c10d4a3..adad03a2 100644 --- a/backend/.editorconfig +++ b/backend/.editorconfig @@ -23,3 +23,30 @@ 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/Directory.Packages.props b/backend/Directory.Packages.props index 1c86ea69..221b97d8 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -14,6 +14,12 @@ + + @@ -37,18 +43,23 @@ - + - + diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs new file mode 100644 index 00000000..69f34f32 --- /dev/null +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -0,0 +1,245 @@ +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 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 +/// 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). +/// +/// +/// 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 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 +/// 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"; + + /// The one role a runtime process may connect as. + internal const string RuntimeRole = "learnstack_app"; + + public static IServiceCollection AddLearnStackPersistence( + this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.TryAddSingleton(_ => + BuildApplicationDataSource(configuration.GetConnectionString(DefaultConnectionName))); + + // 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; + } + + /// + /// 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: " + + $"{RedactUnparsed(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(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 " + + "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(); + + // 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 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() + : command.ExecuteScalar(); + + if (bypasses is true) + { + throw new InvalidOperationException( + "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."); + } + } + + /// 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 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) + { + 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.Api/LearnStack.Api.csproj b/backend/src/LearnStack.Api/LearnStack.Api.csproj index 64e21d7b..f5d81ee3 100644 --- a/backend/src/LearnStack.Api/LearnStack.Api.csproj +++ b/backend/src/LearnStack.Api/LearnStack.Api.csproj @@ -49,6 +49,17 @@ + + + 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.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.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.Application/Pipeline/TransactionBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs index e8780387..03ac8991 100644 --- a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs @@ -1,42 +1,165 @@ +using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; using MediatR; +using Microsoft.Extensions.Logging; 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: 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. +/// +/// 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 — 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. +/// +/// +/// 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 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 +/// the write has somewhere to go. +/// /// -public sealed class TransactionBehavior +public sealed class TransactionBehavior( + IUnitOfWork unitOfWork, + ITenantContext tenantContext, + ILogger> logger) : 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-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). + // 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. + await using var scope = await unitOfWork.BeginTransactionAsync(cancellationToken); - return next(); + 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) + { + // 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. It does + // not mark the unit — see the class remarks. + await scope.FailAsync(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. + committing = true; + await scope.CompleteAsync(cancellationToken); + return response; + } + 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. + // + // 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 + // ExceptionDispatchInfo, and the L1 IExceptionHandler turns it into + // Problem Details. + 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/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 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..a41545a0 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20260828085701_create_platform_infrastructure_tables.cs @@ -0,0 +1,160 @@ +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), + -- 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 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/ModuleDbContextRegistration.cs b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs new file mode 100644 index 00000000..06f8a73b --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs @@ -0,0 +1,151 @@ +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 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 +/// 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 +{ + /// + /// The context types registered through + /// on this collection. + /// + /// + /// 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(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) + { + var existing = services.FirstOrDefault( + descriptor => descriptor.ServiceType == typeof(RegistrationMarker)); + + if (existing?.ImplementationInstance is RegistrationMarker marker) + { + return marker; + } + + marker = new RegistrationMarker(); + services.AddSingleton(marker); + + return marker; + } + + /// + /// 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); + + // 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; + } + + Marker(services).Contexts.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) + // 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)!; + 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..b94d7ce2 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs @@ -0,0 +1,442 @@ +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, 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 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 +{ + private readonly NpgsqlDataSource _dataSource = + dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + + 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; + + 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 (_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(); + + if (_connection.State != System.Data.ConnectionState.Open) + { + await _connection.OpenAsync(cancellationToken); + } + + _transaction = await _connection.BeginTransactionAsync(cancellationToken); + _generation++; + + // Scoped to this transaction, like the generation above it. The flag + // was never cleared, so a unit that committed and then opened a second + // transaction carried the first one's request into the second one's + // disposal: an ordinary abandoned transaction was reported as a + // swallowed commit, and DisposeAsync threw a diagnostic about a nested + // frame nobody had opened — over whatever exception was already in + // flight. + _commitRequested = false; + } + + return new Frame(this, ++_depth, _generation); + } + + 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 Task CommitAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + EnsureFrameOpen(); + + return CommitFrameAsync(cancellationToken); + } + + public Task RollbackAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + // 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() + { + 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. + var swallowedCommit = _commitRequested && _transaction is not null; + + try + { + if (_transaction is not null) + { + await RollbackCoreAsync(); + } + } + finally + { + // 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) + { + // 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() + { + var transaction = _transaction; + _transaction = null; + _depth = 0; + + if (transaction is null) + { + return; + } + + 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() + { + if (_depth == 0 || _transaction is null) + { + throw new InvalidOperationException( + "No transaction frame is open. CommitAsync resolves 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. + /// + /// 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, int generation) + : IUnitOfWorkScope + { + private bool _resolved; + + public bool IsOwner => depth == 1; + + 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) + { + // 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; + } + + EnsureInnermost(); + _resolved = true; + return unitOfWork.CommitAsync(cancellationToken); + } + + public Task FailAsync(CancellationToken cancellationToken = default) + { + if (IsStale() || 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 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; + + /// The transaction this frame belongs to is over. + private bool IsStale() => unitOfWork._generation != generation; + + 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.Infrastructure/Persistence/PlatformDbContext.cs b/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContext.cs new file mode 100644 index 00000000..65cd0b0b --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContext.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore; + +namespace LearnStack.Infrastructure.Persistence; + +/// +/// The platform's own tables — the ones no module owns. +/// +/// +/// +/// outbox_messages and idempotency_keys belong to no module: they +/// are reached through SharedKernel ports by any module's handler and read by +/// infrastructure that belongs to none of them. IIdempotencyStore ships +/// today, behind InMemoryIdempotencyStore until its ADR-0035 trigger +/// fires; IOutbox does not exist yet and lands with the dispatcher in +/// Phase 02b. +/// Nothing writes either table at runtime — Packet 6 shipped the tables. 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 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 +/// 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. + 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..c1d6c979 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContextFactory.cs @@ -0,0 +1,55 @@ +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 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 +{ + private const string ConnectionStringVariable = "ConnectionStrings__Migration"; + + /// + /// 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); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + $"{ConnectionStringVariable} is not set in the environment. " + + "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(HistoryTable)) + .Options; + + return new PlatformDbContext(options); + } +} diff --git a/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs b/backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs index 7e1aefd3..1c74fb52 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,34 +90,125 @@ public void MarkCreated(DateTimeOffset at, UserId by) public void MarkUpdated(DateTimeOffset at, UserId by) { EnsureValidAuditInput(at, by); - - UpdatedAt = at; - UpdatedBy = by; + EnsureCreated(); + Touch(at, 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); + EnsureCreated(); + + 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); + } + + /// + /// 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; + + // `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 - // (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/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/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..68271515 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Identifiers/TenantId.cs @@ -0,0 +1,46 @@ +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. 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, 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. +/// +/// +[ValueObject(LearnStackVogenDefaults.IdMask)] +public readonly partial record struct TenantId : IStronglyTypedId; 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/backend/src/LearnStack.SharedKernel/Persistence/IOptimisticConcurrency.cs b/backend/src/LearnStack.SharedKernel/Persistence/IOptimisticConcurrency.cs index 91008598..a5fa2dbf 100644 --- a/backend/src/LearnStack.SharedKernel/Persistence/IOptimisticConcurrency.cs +++ b/backend/src/LearnStack.SharedKernel/Persistence/IOptimisticConcurrency.cs @@ -10,9 +10,24 @@ 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. /// - uint Version { get; } + /// + /// + /// 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 + /// 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 + /// update succeeds. + /// + /// + long Version { get; } } diff --git a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs new file mode 100644 index 00000000..03b32110 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs @@ -0,0 +1,198 @@ +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 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 +/// 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. 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); + + /// + /// 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. + /// + /// + /// 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. + /// + /// + /// + /// 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 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; 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(); +} + +/// +/// A handle for one frame of the ambient transaction. +/// +/// +/// +/// 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 +{ + /// + /// 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. + /// + /// + /// 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/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..8490e659 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/CompositeKeyedEntities.cs @@ -0,0 +1,321 @@ +using LearnStack.SharedKernel.Domain; +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 in canonical case — tr-TR, en-US, zh-Hans-CN. + /// + /// + /// Canonicalized by on the way in, because this is half + /// of the primary key: en-US and en-us are the same locale and + /// would otherwise be two rows for one tenant, which is exactly the duplicate + /// the composite key exists to prevent. The same argument + /// TenantDomain makes for running its host through + /// EffectiveHost.Normalize. + /// + 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); + MappedLength.EnsureAtMost(locale, 35, nameof(locale)); + LocaleTag.EnsureWellFormed(locale, nameof(locale)); + + TenantOwned.EnsureRealTenant(tenantId, "A locale belongs to a tenant.", nameof(tenantId)); + + return new TenantLocale + { + TenantId = tenantId, + Locale = LocaleTag.Canonicalize(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); + MappedLength.EnsureAtMost(key, 200, nameof(key)); + JsonValue.EnsureWellFormed(value, nameof(value)); + + // 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 + { + TenantId = tenantId, + Key = key, + Value = value, + UpdatedAt = at, + UpdatedBy = by, + }; + } + + public void SetValue(string value, DateTimeOffset at, UserId by) + { + JsonValue.EnsureWellFormed(value, nameof(value)); + AuditInput.EnsureValid(at, by); + + Value = value; + UpdatedAt = at; + UpdatedBy = 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. +/// +/// +/// 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); + } + } +} + +/// +/// 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); + } + } + + /// + /// The tag in BCP-47 canonical case: language lowercase, a 4-letter script + /// subtag Title-cased, a 2-letter region uppercased. Everything else is + /// lowercased. + /// + /// + /// Case is not significant in BCP-47, which is precisely the problem for a + /// column that is half a primary key: without this, `en-US` and `en-us` are two + /// rows naming one locale. + /// + public static string Canonicalize(string value) + { + var parts = value.Split('-'); + + for (var i = 0; i < parts.Length; i++) + { + var part = parts[i].ToLowerInvariant(); + + if (i > 0 && part.Length == 4 && !char.IsDigit(part[0])) + { + // A 4-letter subtag in this position is a script: Title case. + part = char.ToUpperInvariant(part[0]) + part[1..]; + } + else if (i > 0 && part.Length == 2) + { + // A 2-letter subtag after the language is a region: uppercase. + part = part.ToUpperInvariant(); + } + + parts[i] = part; + } + + return string.Join('-', parts); + } + + // 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/Enums.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Enums.cs new file mode 100644 index 00000000..f0ba2d2e --- /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..66896c97 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Organization.cs @@ -0,0 +1,202 @@ +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 — +/// TenancyConventionTests, introduced with this aggregate — 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. + /// 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; } + + /// 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. + /// 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; } + + /// + /// 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); + EnsureWithinMappedLengths(slug, displayName); + UrlSlug.EnsureUrlSafe(slug, nameof(slug)); + + if (!id.IsInitialized() || id.Value == Guid.Empty) + { + throw new ArgumentException( + "The identifier was never assigned; construct it through its factory.", + nameof(id)); + } + + TenantOwned.EnsureRealTenant( + tenantId, + "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); + EnsureWithinMappedLengths(Slug, displayName); + + MarkUpdated(clock.UtcNow, updatedBy); + DisplayName = displayName; + } + + /// + /// The two bounds OrganizationConfiguration maps: 63 for the slug, + /// which is a DNS label, and 200 for the display name. + /// + private static void EnsureWithinMappedLengths(string slug, string displayName) + { + MappedLength.EnsureAtMost(slug, 63, nameof(slug)); + MappedLength.EnsureAtMost(displayName, 200, nameof(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); + + // Stamped first — see Tenant.ChangeStatus. + MarkUpdated(clock.UtcNow, updatedBy); + Status = status; + } + + 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/PlatformProjections.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs new file mode 100644 index 00000000..50396c61 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/PlatformProjections.cs @@ -0,0 +1,135 @@ +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, 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 +{ + 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..fb9e955f --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs @@ -0,0 +1,189 @@ +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); + MappedLength.EnsureAtMost(slug, 63, nameof(slug)); + MappedLength.EnsureAtMost(displayName, 200, nameof(displayName)); + UrlSlug.EnsureUrlSafe(slug, nameof(slug)); + + 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) + { + 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() || organizationId.Value == Guid.Empty) + { + throw new ArgumentException( + "The default organization must be a real organization.", nameof(organizationId)); + } + + MarkUpdated(clock.UtcNow, updatedBy); + DefaultOrganizationId = organizationId; + } + + /// 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); + + // 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) + { + 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 new file mode 100644 index 00000000..df14e3b1 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantDomain.cs @@ -0,0 +1,237 @@ +using LearnStack.SharedKernel.Domain; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; +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); + + /// + /// 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); + + MarkUpdated(clock.UtcNow, updatedBy); + Status = TenantDomainStatus.Verifying; + } + + /// Records a verification attempt that succeeded. + public void MarkVerified(IClock clock, UserId updatedBy) + { + ArgumentNullException.ThrowIfNull(clock); + 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; + } + + /// Records a verification attempt that failed. + 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); + + MarkUpdated(clock.UtcNow, updatedBy); + + Status = TenantDomainStatus.Failed; + VerificationAttempts++; + LastVerificationError = error; + } + + /// + /// 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."); + } + } + + /// + /// 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, + string host, + TenantDomainKind kind, + IClock clock, + UserId createdBy) + { + ArgumentNullException.ThrowIfNull(clock); + ArgumentException.ThrowIfNullOrWhiteSpace(host); + + if (!id.IsInitialized() || id.Value == Guid.Empty) + { + throw new ArgumentException( + "The identifier was never assigned; construct it through its factory.", + nameof(id)); + } + + 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 + // 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.", + 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..fcfa8d37 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/TenantSetting.cs @@ -0,0 +1,130 @@ +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); + MappedLength.EnsureAtMost(key, 200, nameof(key)); + JsonValue.EnsureWellFormed(value, nameof(value)); + + 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() || 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, + 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); + JsonValue.EnsureWellFormed(value, nameof(value)); + + // Stamped first — see Tenant.ChangeStatus. + MarkUpdated(clock.UtcNow, updatedBy); + Value = value; + } +} 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..0c586c16 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Configurations.cs @@ -0,0 +1,360 @@ +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. + /// + /// + /// + /// 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 + /// 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() + .ValueGeneratedNever(); + } + + /// + /// 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. 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)) + .HasColumnType("text"); + +} + +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().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().IsRequired(); + + 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 + // 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"); + + // 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(); + } +} + +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().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, 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(); + } +} + +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(); + + // 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) + .HasFilter("deleted_at IS NULL") + .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(); + + // 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() + .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(); + + // 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(); + } +} + +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(); + + // 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 new file mode 100644 index 00000000..9a98e903 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.Designer.cs @@ -0,0 +1,492 @@ +// +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("20260828092437_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() + .HasColumnType("text") + .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() + .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", "ReportingParentId") + .HasDatabaseName("ix_organizations_tenant_id_reporting_parent_id"); + + b.HasIndex("TenantId", "Slug") + .IsUnique() + .HasDatabaseName("ux_organizations_tenant_id_slug") + .HasFilter("deleted_at IS NULL"); + + 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") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("refreshed_at") + .HasDefaultValueSql("now()"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .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", "OrganizationId") + .HasDatabaseName("ix_platform_host_to_tenant_tenant_id_organization_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() + .HasColumnType("text") + .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() + .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() + .HasColumnType("text") + .HasColumnName("kind"); + + b.Property("LastVerificationError") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("last_verification_error"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .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() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_tenant_domains"); + + b.HasIndex("Host") + .IsUnique() + .HasDatabaseName("ux_tenant_domains_host") + .HasFilter("deleted_at IS NULL"); + + 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") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + 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() + .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") + .HasFilter("deleted_at IS NULL"); + + 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/20260828092437_create_tenancy_schema.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.cs new file mode 100644 index 00000000..77ea1265 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/20260828092437_create_tenancy_schema.cs @@ -0,0 +1,608 @@ +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: "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), + 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, defaultValueSql: "now()"), + source = table.Column(type: "text", 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: "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), + 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, defaultValueSql: "now()"), + 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: "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), + 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: "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", + columns: new[] { "tenant_id", "id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ux_organizations_tenant_id_slug", + table: "organizations", + columns: new[] { "tenant_id", "slug" }, + unique: true, + filter: "deleted_at IS NULL"); + + migrationBuilder.CreateIndex( + name: "ix_platform_host_to_tenant_tenant_id_organization_id", + table: "platform_host_to_tenant", + columns: new[] { "tenant_id", "organization_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, + filter: "deleted_at IS NULL"); + + 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, + filter: "deleted_at IS NULL") + .Annotation("Npgsql:NullsDistinct", false); + + 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); + """); + + // ── 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) + { + // 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. + + // 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: "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: "platform_entitlement_cache"); + + migrationBuilder.DropTable( + 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 new file mode 100644 index 00000000..93bb58b0 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/Migrations/TenancyDbContextModelSnapshot.cs @@ -0,0 +1,489 @@ +// +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() + .HasColumnType("text") + .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() + .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", "ReportingParentId") + .HasDatabaseName("ix_organizations_tenant_id_reporting_parent_id"); + + b.HasIndex("TenantId", "Slug") + .IsUnique() + .HasDatabaseName("ux_organizations_tenant_id_slug") + .HasFilter("deleted_at IS NULL"); + + 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") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("refreshed_at") + .HasDefaultValueSql("now()"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .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", "OrganizationId") + .HasDatabaseName("ix_platform_host_to_tenant_tenant_id_organization_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() + .HasColumnType("text") + .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() + .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() + .HasColumnType("text") + .HasColumnName("kind"); + + b.Property("LastVerificationError") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("last_verification_error"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .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() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_tenant_domains"); + + b.HasIndex("Host") + .IsUnique() + .HasDatabaseName("ux_tenant_domains_host") + .HasFilter("deleted_at IS NULL"); + + 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") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + 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() + .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") + .HasFilter("deleted_at IS NULL"); + + 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 new file mode 100644 index 00000000..a29b75ed --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/SnakeCaseNaming.cs @@ -0,0 +1,130 @@ +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. +/// TenancySchemaTests.EveryMappedIdentifierIsSnakeCase sweeps the applied +/// catalogue for the survivors; the convention 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..73c4af36 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenancyDbContextFactory.cs @@ -0,0 +1,81 @@ +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 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. +/// +/// +public sealed class TenancyDbContextFactory : IDesignTimeDbContextFactory +{ + private const string ConnectionStringVariable = "ConnectionStrings__Migration"; + + /// + /// 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); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + $"{ConnectionStringVariable} is not set in the environment. " + + "Migrations run as learnstack_migration, which owns every table; the value " + + "is in .env.example and `make migrate` is its sanctioned carrier. " + + "`dotnet ef --connection` does not help here — EF applies it after this " + + "factory returns. 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(HistoryTable)) + .Options; + + return new TenancyDbContext(options); + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs index 189cae66..76831b02 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs @@ -421,14 +421,33 @@ 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); 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,26 +460,72 @@ 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 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) + { + 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 }; + + // 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; - 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))); } @@ -476,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..2ba42f7c --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -0,0 +1,539 @@ +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; + +/// +/// 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"); + } + + [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 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(); + + contexts.Should().NotBeEmpty("TenancyDbContext is registered and is the first consumer"); + + contexts.Should().OnlyContain( + descriptor => registered.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. + // + // 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 => ProviderTokens.Any(token => + StripComments(File.ReadAllText(file)).Contains(token, StringComparison.Ordinal))) + .Select(Path.GetFileName) + .Order(StringComparer.Ordinal) + .ToList(); + + 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() + { + // 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. + // 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; + + 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(); + + 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. + /// + /// + /// 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) => 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() + { + // 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() + { + // `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"); + } + + [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"); + } + + [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() + { + // 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; + + // 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(); + 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. + /// + 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. + /// + /// + /// 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/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 423d15c8..9db20431 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs @@ -98,6 +98,66 @@ public void Assertion_Budget_Does_Not_Depend_On_ICacheService() + "(ADR-0036 § Recording a rejected assertion)"); } + [Theory] + // `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 + // 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(); + + 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)) + .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 @@ -162,11 +222,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}'"); } @@ -175,164 +236,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/CrossCuttingFoundationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs index bae5c890..2e56299b 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,82 @@ 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 Task FailAsync(CancellationToken cancellationToken = default) => + unitOfWork.RollbackAsync(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/DatabaseRoleTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs new file mode 100644 index 00000000..a3c5cb95 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/DatabaseRoleTests.cs @@ -0,0 +1,401 @@ +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, rolsuper, rolcreatedb, rolcreaterole 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"); + + // 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] + 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); + // 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 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(false); + } + + [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"); + + // 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] + 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 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. + // 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); + + (await who.ExecuteScalarAsync()).Should().Be(expected, + "the fixture's {0} connection string must authenticate as {0}", expected); + } + } + + [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] + 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 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"); + } + } + + [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 + { + // 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( + "REVOKE learnstack_platform FROM learnstack_app"); + } + } + + /// + /// 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); + 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() + { + // 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/MigrationRollbackTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/MigrationRollbackTests.cs new file mode 100644 index 00000000..68929f1e --- /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(TenancyDbContextFactory.HistoryTable)) + .Options); + + private PlatformDbContext CreatePlatform() => + new(new DbContextOptionsBuilder() + .UseNpgsql(Postgres.MigrationConnectionString, npgsql => + 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 new file mode 100644 index 00000000..874055de --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/PlatformSchemaTests.cs @@ -0,0 +1,252 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// outbox_messages and idempotency_keys — the two tables no module +/// owns — and the behaviour that is specific to them. +/// +/// +/// +/// 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)] +[Collection(SharedSchema.Name)] +public sealed class PlatformSchemaTests +{ + private readonly SchemaFixture _schema; + + public PlatformSchemaTests(SchemaFixture schema) => _schema = schema; + + [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 SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.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", SchemaFixture.TenantA); + + (await insert.ExecuteScalarAsync()).Should().Be((short)7); + } + + [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. 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(statement, (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 TheDispatcherReadsEveryTenantWithNoTenantContext() + { + // 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( + "SELECT count(DISTINCT tenant_id) FROM outbox_messages", (NpgsqlConnection)connection); + + (await command.ExecuteScalarAsync()).Should().Be(2L, + "the fixture seeds one row per tenant and the dispatcher sees both"); + } + + [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 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); + } + } + + [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 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) + VALUES (@tenant, {key}, 'fp', uuidv7(), {state}, now() + interval '5 minutes') + """, + ("tenant", SchemaFixture.TenantA)); + + if (violated is null) + { + await act.Should().NotThrowAsync(); + } + else + { + (await act.Should().ThrowAsync()) + .Which.ConstraintName.Should().Be(violated); + } + } + + [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); + } + } + + [Fact] + public async Task EachChainHasItsOwnHistoryTable() + { + // 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( + """ + SELECT string_agg(tablename, ',' ORDER BY tablename) + FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE '\_\_ef%' + """, (NpgsqlConnection)connection); + + 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/PostgresFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs new file mode 100644 index 00000000..1780b270 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/PostgresFixture.cs @@ -0,0 +1,231 @@ +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 are exact complements. `backend` runs +/// --filter "Requires!=Docker" and `backend-integration` runs +/// --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, +/// 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 +{ + 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, + ]); + + /// + /// 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) + { + 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 +/// 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 +{ + 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); + + // 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}. " + + "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/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 new file mode 100644 index 00000000..db3e2ce7 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs @@ -0,0 +1,647 @@ +using FluentAssertions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// The applied schema — both chains — its Row Level Security policies and its +/// grants, asserted against a real PostgreSQL with the four roles provisioned. +/// +/// +/// +/// 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 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, 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 — +/// 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)] +[Collection(SharedSchema.Name)] +public sealed class TenancySchemaTests +{ + private readonly SchemaFixture _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 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 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 SchemaQueries.ReadStringsAsync(connection, + $""" + SELECT relname FROM pg_class + WHERE oid IN ({SchemaQueries.TableOids}) AND NOT (relrowsecurity AND relforcerowsecurity) + """); + + unprotected.Should().BeEmpty("ENABLE without FORCE lets the owner bypass its own policies"); + } + + [Fact] + 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. + // + // 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); + + var offenders = await SchemaQueries.ReadStringsAsync(connection, + """ + SELECT tablename || ' (' || string_agg(policyname, ', ' ORDER BY policyname) || ')' + FROM pg_policies + 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 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 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 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(SchemaFixture.KnownTables); + } + + [Fact] + public async Task Tenant_A_cannot_read_Tenant_B_data() + { + // 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. + 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 seenByA = await SchemaQueries.CountEveryTableAsync(connection, transaction); + + seenByA.Should().BeEquivalentTo(SchemaFixture.RowsVisibleToTenantA); + } + + await using (var transaction = await connection.BeginTransactionAsync()) + { + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantB); + var seenByB = await SchemaQueries.CountEveryTableAsync(connection, transaction); + + seenByB.Should().BeEquivalentTo(SchemaFixture.RowsVisibleToTenantB); + } + } + + [Fact] + 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. 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 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", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + + (await command.ExecuteScalarAsync()).Should().Be(0L, + "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] + // 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("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) + { + await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantB); + + await using var command = new NpgsqlCommand( + statement, (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + command.Parameters.AddWithValue("foreign", SchemaFixture.TenantA); + + if (statement.Contains("@actor", StringComparison.Ordinal)) + { + command.Parameters.AddWithValue("actor", SchemaFixture.Actor); + } + + 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 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 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. + // + // 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 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", 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", 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", SchemaFixture.OrgA2); + (await delete.ExecuteNonQueryAsync()).Should().Be(0, + "tenant_settings_org_delete_guard is the only gate DELETE has"); + } + } + + [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 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", SchemaFixture.OrgA1); + + 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. + // + // 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 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(SchemaFixture.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 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", SchemaFixture.TenantA), ("host", Host), ("actor", SchemaFixture.Actor)); + + await SchemaQueries.ExecuteAsync(connection, transaction, + "UPDATE tenant_domains SET deleted_at = now(), deleted_by = @actor WHERE host = @host", + ("actor", SchemaFixture.Actor), ("host", Host)); + + 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", SchemaFixture.TenantB), ("host", Host), ("actor", SchemaFixture.Actor)); + + await reclaim.Should().NotThrowAsync(); + } + + [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. 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); + + var scanned = await SchemaQueries.ReadStringsAsync(connection, SchemaQueries.TableNames); + scanned.Should().Contain(SchemaFixture.KnownTables); + + 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 ({SchemaQueries.TableOids}) + AND a.attnum > 0 AND NOT a.attisdropped + AND a.attname <> lower(a.attname) + """); + + offenders.Should().BeEmpty(); + } + + [Fact] + 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 + // 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. 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 SchemaQueries.ReadStringsAsync(connection, + """ + SELECT grantee || ' ' || table_name || ' ' || string_agg(privilege_type, ',' ORDER BY privilege_type) + FROM information_schema.role_table_grants + 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_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", + "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_platform outbox_messages DELETE,SELECT", + "learnstack_platform idempotency_keys DELETE,SELECT", + "learnstack_outbox_admin outbox_messages SELECT", + ], + "every line is one line of the two migrations' grant matrices, and " + + "learnstack_outbox_admin holds nothing beyond the outbox"); + } +} 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..949481e6 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -0,0 +1,767 @@ +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; +using Microsoft.Extensions.Logging.Abstractions; +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(); + + // 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(); + + 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(); + } + + [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 An_absorbed_inner_failure_still_commits_the_outer_work() + { + // 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), + NullLogger>>.Instance); + var inner = new TransactionBehavior>( + unitOfWork, + Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1), + NullLogger>>.Instance); + + 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(); + + var outer = await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync(Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + + 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(); + } + + [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 — 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(); + + (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() + { + // 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 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 + { + // 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"); + } + } + + [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*"); + 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(); + } + } + + [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); + } + } + + [Fact] + public async Task An_abandoned_transaction_after_a_committed_one_is_just_rolled_back() + { + // The swallowed-commit diagnostic is for a nested frame nobody resolved. + // With the flag left set by an earlier, entirely correct commit, an + // ordinary abandoned transaction tripped it instead — and DisposeAsync + // threw that diagnostic over whatever exception had abandoned the + // transaction in the first place. + await using var provider = BuildProvider(); + var committed = $"00-uow-{Guid.CreateVersion7():N}"; + var abandoned = $"00-uow-{Guid.CreateVersion7():N}"; + + try + { + var dispose = async () => + { + await using var scope = provider.CreateAsyncScope(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync( + Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + await InsertOutboxAsync(unitOfWork, committed); + await unitOfWork.CommitAsync(); + + // Opened, written, and left for the scope to clean up. + await unitOfWork.BeginTransactionAsync(); + await unitOfWork.SetTenantContextAsync( + Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + await InsertOutboxAsync(unitOfWork, abandoned); + }; + + await dispose.Should().NotThrowAsync( + "an abandoned transaction is rolled back, not reported as a commit " + + "the unit swallowed"); + + (await CountOutboxAsync(committed)).Should().Be(1L); + (await CountOutboxAsync(abandoned)).Should().Be(0L, "it was never committed"); + } + finally + { + await DeleteOutboxAsync(committed); + await DeleteOutboxAsync(abandoned); + } + } + + // ── 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 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 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( + _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 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; + + 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.Integration/LearnStack.Tests.Integration.csproj b/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj index e5809a20..80cf60cc 100644 --- a/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj +++ b/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj @@ -15,6 +15,13 @@ + + @@ -25,6 +32,10 @@ + + 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..69a8115f --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Api/Composition/ApplicationDataSourceGuardTests.cs @@ -0,0 +1,129 @@ +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(); + + // 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 + // 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 + { + PersistenceCompositionExtensions.BuildApplicationDataSource(value); + } + catch (InvalidOperationException exception) + { + messages.Add(exception.Message); + } + } + + 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 new file mode 100644 index 00000000..fd60a98e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs @@ -0,0 +1,399 @@ +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 Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace LearnStack.Tests.Unit.Application.Pipeline; + +/// +/// TransactionBehavior — step 6. The commit boundary: what it calls, in +/// 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 +{ + 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_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); + + 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"); + unitOfWork.Calls.Should().NotContain("mark-rollback-only"); + } + + [Fact] + public async Task Rolls_Back_And_Rethrows_An_Exception_After_Marking_The_Unit() + { + // 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); + + 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", "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] + 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); + } + + [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, + NullLogger>>.Instance); + + private static Task> Next(RecordingUnitOfWork unitOfWork, Result result) + { + unitOfWork.Calls.Add("handler"); + return Task.FromResult(result); + } + + /// 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; } + + /// + /// 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 => + throw new NotSupportedException("the behavior must never reach for the connection"); + + public DbTransaction? Transaction => null; + + public bool HasActiveTransaction => _depth > 0; + + public Task BeginTransactionAsync(CancellationToken cancellationToken = default) + { + Calls.Add("begin"); + 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; + } + + public Task CommitAsync(CancellationToken cancellationToken = default) + { + Calls.Add("commit"); + + 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"); + --_depth; + + return RollbackFailure is null + ? Task.CompletedTask + : Task.FromException(RollbackFailure); + } + + 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/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..8570dba7 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenancyAggregateTests.cs @@ -0,0 +1,588 @@ +using FluentAssertions; +using LearnStack.Modules.Tenancy.Domain; +using TenancyDomain = 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.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(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] + // 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(); + } + + [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(); + } + } + + [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*"); + } + + [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); + + [Theory] + // Every mapped text bound, asserted where the value is set. The database + // reports 22001 with no property name, three layers from the call. + // + // 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, locale, isDefault: true); + + if (accepted) + { + create.Should().NotThrow(); + } + else + { + create.Should().Throw().WithMessage("*the column holds 35*"); + } + } + + [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")] + [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)] + 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. + /// + /// + /// 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/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/AuditableEntityTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Domain/AuditableEntityTests.cs index 7a476995..f25da1e1 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; @@ -69,6 +70,121 @@ 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. + // 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 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() + { + // 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] 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..2b4fc943 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Identifiers/TenancyIdentifierTests.cs @@ -0,0 +1,122 @@ +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 = 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); + } + + [Theory] + [InlineData(typeof(TenantId))] + [InlineData(typeof(OrganizationId))] + public void NeitherIdentifierConvertsImplicitlyToOrFromItsPrimitive(Type idType) + { + // 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 wrappers must 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() / 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/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/04-technical-architecture.md b/docs/architecture/04-technical-architecture.md index a9cf6f45..351fac04 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. @@ -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`. | -| Concurrency | `row_version` (`xmin` or explicit `bigint` column) on mutable entities. | +| 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/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index c36b3d7a..ffb9b4d1 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -112,8 +112,18 @@ 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 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 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/12-localization.md b/docs/architecture/12-localization.md index 2e7a57d3..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 TEXT NOT NULL, + 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, @@ -41,6 +41,16 @@ 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. + +`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. @@ -248,10 +258,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/architecture/21-feature-flags.md b/docs/architecture/21-feature-flags.md index dfc588c6..24d2c6cf 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 @@ -133,10 +133,20 @@ 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')) ); ``` +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/architecture/27-custom-domain-tls.md b/docs/architecture/27-custom-domain-tls.md index 10d17100..87f42f50 100644 --- a/docs/architecture/27-custom-domain-tls.md +++ b/docs/architecture/27-custom-domain-tls.md @@ -217,32 +217,91 @@ 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); } 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( - $"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 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: + // `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 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; + }, 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..dd0d4c23 100644 --- a/docs/architecture/31-audit-subsystem.md +++ b/docs/architecture/31-audit-subsystem.md @@ -427,8 +427,12 @@ 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. 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); @@ -477,8 +481,30 @@ 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. + +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 +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/0002-initial-architecture.md b/docs/decisions/0002-initial-architecture.md index 39f6f9ba..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 @@ -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 3b818f6d..36e999e0 100644 --- a/docs/decisions/0003-tenant-isolation-defense-in-depth.md +++ b/docs/decisions/0003-tenant-isolation-defense-in-depth.md @@ -165,6 +165,15 @@ Its binding properties: ### Which tables the template applies to +> **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: @@ -267,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/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..7f5b55bc 100644 --- a/docs/decisions/0023-strongly-typed-id-source-generator.md +++ b/docs/decisions/0023-strongly-typed-id-source-generator.md @@ -33,6 +33,12 @@ 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. +> **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 @@ -87,6 +93,12 @@ 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 `gen_uuid_v7()`), and a `[Description]`/`[ReadOnly]` annotation surface Roslyn @@ -151,6 +163,12 @@ Three things outweighed the appeal: ### What we explicitly punted on +> **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 @@ -232,6 +250,16 @@ 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 (`gen_uuid_v7()` per ADR-0031) for `audit_log`, `outbox_messages`, `inbox_messages`, `idempotency_keys` — high-volume append-only tables. @@ -339,6 +367,67 @@ Both rules are written in This is a clarification; the Decision is unchanged. +### Amendment 4 — `idempotency_keys` mints no id (2026-08-27) + +§ 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 +`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 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 3c400e8e..a12acf60 100644 --- a/docs/decisions/0031-postgresql-major-version.md +++ b/docs/decisions/0031-postgresql-major-version.md @@ -18,6 +18,12 @@ 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. +> **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 @@ -95,6 +101,12 @@ 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 | |------------|--------------------| | `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 | @@ -167,18 +179,80 @@ 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 `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. +## 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`: + +```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 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. + +**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 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. + +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) +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 `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/0037-idempotency-key-contract.md b/docs/decisions/0037-idempotency-key-contract.md index 23c07c06..282d2573 100644 --- a/docs/decisions/0037-idempotency-key-contract.md +++ b/docs/decisions/0037-idempotency-key-contract.md @@ -419,7 +419,154 @@ will replay is worth a log line rather than silence. ## Amendments -None yet. +### 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 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 +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. + +### 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. `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() 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() 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 +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. + +### 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 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..f5633df6 --- /dev/null +++ b/docs/decisions/0039-optimistic-concurrency-token.md @@ -0,0 +1,327 @@ +# 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 + **`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: + + | 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. **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) +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 + 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 + +### 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. + +### 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) +- [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..a184c35f --- /dev/null +++ b/docs/decisions/0040-ambient-unit-of-work.md @@ -0,0 +1,461 @@ +# 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 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.** + [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. + +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 +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 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. + /// 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 + /// 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); + + 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 `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 + 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**: +`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 +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 + +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. **Shipped**, Packet 6. +- `TransactionBehavior_Does_Not_Reference_A_Module_Assembly` — the behavior + 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 + +### 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. + +### 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. + +**`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. + +## 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/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..56a94456 --- /dev/null +++ b/docs/decisions/0041-correcting-false-statements-in-accepted-adrs.md @@ -0,0 +1,396 @@ +# ADR-0041: Correcting False Statements in Accepted ADRs + +## Status + +Accepted + +**Date:** 2026-08-28 **Deciders:** @platform +**Accepted:** 2026-08-29 + +## Decision Drivers + +- **The written rule and the practised rule disagree, and the practised rule is + 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 + 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 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 + 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 at +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. 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 + +The first draft's answer: replace the false text, record it in a dated amendment +naming every carrier. + +- **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:** 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 D.** + +### The default: inline erratum + +An Accepted ADR's body is **not edited**. Where it carries a statement that was +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 +**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 + +Replacement is licensed only when **all three** hold: + +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) 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 does not +license touching the ADRs alongside it. + +### 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 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 *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 + +- 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 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. + +### Negative + +- 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** 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 99 + days and a merge unnoticed. + +### Neutral + +- No existing ADR is superseded. + +## Implementation Notes + +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. + +**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) | "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:** + +- [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. + +**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** | +| [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 +**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`, `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. + +**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 +([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: 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 + 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 07e8df1c..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. +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 @@ -42,7 +44,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 | @@ -50,6 +52,9 @@ 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 `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) | 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 a7a870a6..f853df4a 100644 --- a/docs/decisions/template.md +++ b/docs/decisions/template.md @@ -74,15 +74,28 @@ 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 -- [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/glossary.md b/docs/glossary.md index 9f6540c8..c567e447 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 — 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`. | +| **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. | @@ -256,7 +260,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)]`. | @@ -282,14 +286,17 @@ 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. | +| **`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}`. | | **`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. | | **`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. | +| **`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. | ## Hub & Licensing @@ -304,6 +311,16 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **Platform Entitlement Cache** | The `platform_entitlement_cache` table in LearnStack core — the **durable** read-only mirror of the Hub-side Entitlement Aggregate, carrying `valid_until` and `grace_until`. Despite the name it is a projection store, not a cache: the volatile layers are L1 and L2 in front of it. Third in the normative read path `L1 → L2 → platform_entitlement_cache → Hub` ([ADR-0034](decisions/0034-hub-contract-surface-invariant.md)). Eager-invalidated on the entitlement-updated event; modules never read the table directly (`Modules_Do_Not_Read_Entitlement_Cache_Directly`). | | **Operator Portal** | `operator-portal` — the separate Next.js app for Hub operators. Authenticates against the `learnstack-hub` Keycloak realm. | +## Decision Records + +| Term | Definition | +|---|---| +| **Amendment** | A dated, append-only entry at the bottom of an ADR. The only way to add to an accepted record, and the disclosure both correction mechanisms below owe — **in every Accepted ADR a diff changes**, because an amendment in one ADR discloses nothing to a reader of another. A correction amendment names what was wrong, how it was shown wrong, and every carrier changed, and restates the Decision as unchanged ([ADR-0041](decisions/0041-correcting-false-statements-in-accepted-adrs.md)). | +| **Inline Erratum** | The **default** way to correct an Accepted ADR that says something false: a dated blockquote placed immediately before the paragraph or fence it corrects — and immediately *below* a heading when the span is a subsection, since a reader arriving on the anchor starts their viewport there. The body is not edited, so the accepted wording survives in the document rather than only in `git log -L`. Shape and rules in [13-documentation.md § Correcting and Amending ADRs](standards/13-documentation.md). | +| **In-Place Replacement** | The bounded **exception** to the erratum default: the false text is rewritten. Licensed only when the statement was false **when it entered the record**, the text is a *canonical artifact* rather than an *illustrative sketch*, and the diff moves no normative content. A carrier outside the ADRs — source, script, workflow — licenses nothing; correct that carrier on its own. | +| **Canonical Artifact** | Text an ADR presents **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. It travels away from any banner placed beside it, which is why it is the one class in-place replacement covers. The corrected RLS policy template in [ADR-0003](decisions/0003-tenant-isolation-defense-in-depth.md) Amendment 3 is the worked example — four documents had copied it, wrong in all four. | +| **Illustrative Sketch** | Text an ADR presents to be **read**, not applied — a code fence showing the shape of a type, a function named in prose. Not a canonical artifact however copyable it looks, and it gets an erratum. [ADR-0017](decisions/0017-tenant-organization-hierarchy.md)'s namespace fence is the worked example: its own amendment calls it "superseded as illustrative", and the fence still carries the original text. | + ## Roadmap & Delivery | Term | Definition | diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md new file mode 100644 index 00000000..859d3e66 --- /dev/null +++ b/docs/modules/tenancy/README.md @@ -0,0 +1,332 @@ +# Module Spec — Tenancy + +**Status:** Design stable, partially implemented (Phase 02a Packet 6 shipped the +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). + +## 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` — 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 + 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 +``` + +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 + 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 +``` + +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 + +### 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 +``` + +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 +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 +``` + +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 +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 +``` + +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 +([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 + 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 + 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. +- **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 + 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. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 33c579e0..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 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, 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 e9644b9e..22fbbf62 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-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 @@ -14,8 +14,8 @@ > | 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) | -> | 6 | Tenancy schema and the corrected RLS template | ⏳ [scope](#packet-sequence) | +> | 5 | Foundation ports and default implementations | ✅ [record](#delivery-record-packet-5) | +> | 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 @@ -350,19 +352,32 @@ 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. -**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 @@ -454,6 +469,17 @@ 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. +**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 +[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 @@ -477,6 +503,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 @@ -629,10 +664,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. @@ -729,18 +768,46 @@ 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 transport swappable later ([ADR-0006](../decisions/0006-events-and-outbox.md)). +**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 +[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). 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 +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` @@ -751,16 +818,19 @@ 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 | 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) @@ -1205,6 +1275,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 @@ -1941,7 +2013,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 +2065,244 @@ 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. + +## 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 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. +> +> **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 +> +> 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 +> (`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. +> +> 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` 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`, +> `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. 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 +> +> `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`. diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 2f4f081d..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 @@ -77,7 +81,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..edc7d19f 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. @@ -70,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 @@ -144,8 +157,13 @@ 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 @@ -167,6 +185,56 @@ 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` 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: + +```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), [Tenant Isolation](../architecture/09-tenant-isolation.md) and @@ -207,7 +275,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 @@ -258,12 +351,13 @@ 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 | |---|---|---| -| **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` | @@ -308,8 +402,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 @@ -320,6 +425,35 @@ 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 ( + -- 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 +) +``` + +`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; @@ -363,8 +497,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 @@ -398,15 +537,60 @@ 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. +[Infrastructure Standards § Local Infrastructure](12-infrastructure.md) requires +every credential to be `${VAR:-fallback}` in compose with a matching row in +`.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. + +**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 +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 +604,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 +639,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: @@ -532,26 +720,107 @@ 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` -A shared EF interceptor populates these on `SaveChanges`. +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` +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 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 +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 +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 + 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. -- Sequence-based ids only for high-write append-only logs (outbox, audit log). +- `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 @@ -591,11 +860,17 @@ A shared EF interceptor populates these on `SaveChanges`. 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 -- 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. @@ -639,7 +914,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 @@ -725,6 +1000,110 @@ 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 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` +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')), + -- 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. + CONSTRAINT ck_idempotency_keys_key_length + 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 +-- 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: @@ -745,8 +1124,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 @@ -772,5 +1153,18 @@ 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 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. - 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..297df1c7 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. @@ -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 | @@ -57,11 +57,20 @@ 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 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. These arrive with the schema in Packet 7 and run in the separate - `backend-integration` job. + 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 happy path and the edges. diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index cc319161..966379f8 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -253,13 +253,44 @@ 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. 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) | 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 +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 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 -`TenantContextMissingException` when it has not. It cannot be a connection-checkout +`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 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)). @@ -446,10 +477,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/13-documentation.md b/docs/standards/13-documentation.md index ceae775e..4a08e400 100644 --- a/docs/standards/13-documentation.md +++ b/docs/standards/13-documentation.md @@ -73,7 +73,7 @@ The following are kept current and treated as code: | Type | Purpose | Mutability | |------|---------|-----------| | Architecture (`architecture/`) | Conceptual descriptions of what we are building | Editable as the system evolves | -| ADR (`decisions/`) | A one-time decision with status, context, decision, consequences | Immutable after acceptance, except for typo fixes | +| ADR (`decisions/`) | A one-time decision with status, context, decision, consequences | Immutable after acceptance, except for dated Amendments and the bounded corrections in § Correcting and Amending ADRs | | Standard (`standards/`) | Ongoing engineering rules | Editable as the team learns | | Runbook (`runbooks/`) | Operational procedures | Editable; review quarterly | | Roadmap (`roadmap/`) | Phased plan | Editable per phase | @@ -119,7 +119,10 @@ Proposed | Accepted | Superseded | Deprecated Rules: - Numbered sequentially (`0001`, `0002`, ...). Never reused. -- Accepted ADRs are immutable except for typo fixes and dated Amendments. +- Accepted ADRs are immutable except for dated Amendments and the two bounded + correction mechanisms in § Correcting and Amending ADRs. "Typo fixes" was the + earlier wording and had no test attached; [ADR-0041](../decisions/0041-correcting-false-statements-in-accepted-adrs.md) + replaces it with one. - A new decision that supersedes an old one is a **new ADR**; the old one is marked `Superseded by ADR-NNNN` and reduced to a redirect stub. The full stub lives in `decisions/_redirects/` when the file is otherwise empty. @@ -189,7 +192,9 @@ When a module reaches "design stable, ready to implement", it gets a spec under A module spec without these sections is not "done"; reviewers block merges that skip required diagrams. -## ADR Amendments +## Correcting and Amending ADRs + +**Derives from:** [ADR-0041](../decisions/0041-correcting-false-statements-in-accepted-adrs.md) Accepted ADRs are otherwise immutable, but **dated Amendments** are allowed at the bottom of the file for clarifications that do not change the decision: @@ -203,6 +208,34 @@ Accepted ADRs are otherwise immutable, but **dated Amendments** are allowed at t Amendments must not change the Decision section. If the decision itself changes, write a new ADR that supersedes the old one. +### When the body says something false + +An Accepted ADR sometimes carries a statement that was **false when it entered the record** — a function that does not exist, a policy that does not do what the prose beside it claims. Two mechanisms correct it, and the weaker one is the default. Which applies is decided by [ADR-0041](../decisions/0041-correcting-false-statements-in-accepted-adrs.md); this section is the operating rule. + +**Default — inline erratum.** The body is not edited. A dated blockquote goes immediately before the paragraph or fence it corrects, and immediately *below* a heading when the span is a whole subsection, because a reader arriving on the anchor starts their viewport at the heading: + +```markdown +> **Erratum — YYYY-MM-DD.** The below reads ``. It is +> ``; shown by ``. The Decision is +> unchanged. Current authority: [](). Recorded in Amendment N. +``` + +**Exception — in-place replacement.** Only when all three hold: + +1. The statement was false **when it entered the record**. Text from the original body is judged at the acceptance commit; text inside a dated Amendment is judged at *that amendment's* date. A statement that was true then and is stale now is history — it gets an amendment or a superseding ADR, never a rewrite. +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 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:** + +1. **A dated Amendment in every Accepted ADR the diff changes**, naming what was wrong, how it was shown wrong, and every carrier changed. A cross-file carrier list is additive, never a substitute — an amendment in one ADR does not disclose a change made in another. +2. The decision restated as **unchanged**. If it cannot be, the change is a superseding ADR. +3. Two review gates, checked separately: reproducible evidence of falsity at entry, and a diff that moves no normative content. + +**Not a correction at all:** retargeting a moved link with its text unchanged. Nothing the ADR asserts changes, and no amendment is owed. + ## When to Update Documentation | Change | Doc to update | diff --git a/docs/standards/17-code-review.md b/docs/standards/17-code-review.md index 824acf0b..1a000717 100644 --- a/docs/standards/17-code-review.md +++ b/docs/standards/17-code-review.md @@ -1,7 +1,7 @@ # 17 — Code Review Standards **Status:** Active -**Derives from:** [ADR 0003 — Tenant Isolation Defense in Depth](../decisions/0003-tenant-isolation-defense-in-depth.md), [ADR 0010 — Cross-Module Communication](../decisions/0010-cross-module-communication.md) (zero-tolerance blockers map back to these two). +**Derives from:** [ADR 0003 — Tenant Isolation Defense in Depth](../decisions/0003-tenant-isolation-defense-in-depth.md), [ADR 0010 — Cross-Module Communication](../decisions/0010-cross-module-communication.md), [ADR 0041 — Correcting False Statements in Accepted ADRs](../decisions/0041-correcting-false-statements-in-accepted-adrs.md) (every zero-tolerance blocker maps back to one of these three). How LearnStack reviews pull requests. The goal is faster, safer ship — not gatekeeping. @@ -35,6 +35,7 @@ The following findings are always `blocker:`. No discussion needed; the PR does | Public-read ACL on a tenant-scoped object | [11-security.md](11-security.md) § File Uploads | | `Result.Ok(default!)` or other null-success pattern | [09-error-handling.md](09-error-handling.md) § Forbidden | | `DateTime.UtcNow` / `DateTime.Now` in domain or application code | [02-backend-coding.md](02-backend-coding.md) § Time | +| Accepted ADR body edited without a dated Amendment **in that ADR's own file**, naming what was wrong, how it was shown wrong, and every carrier changed | [13-documentation.md](13-documentation.md) § Correcting and Amending ADRs, [ADR-0041](../decisions/0041-correcting-false-statements-in-accepted-adrs.md) | These are not opinions — they map directly to existing standards. If you find one, cite the standard line and request changes. diff --git a/docs/standards/18-audit-coverage.md b/docs/standards/18-audit-coverage.md index 7ad797f7..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 | |----------|:------:|:------:|:------:|:--------------:|:--------------:| @@ -137,9 +137,16 @@ 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 `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) + 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..c9a1fac7 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-two 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) and Packet 4. +[Phase 02a Packets 2–3](../roadmap/phase-02a-kernel-tenancy.md), Packet 4 and +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. **Not every implemented rule lives in that assembly.** Packet 4 added eight rules there — four API-convention ones @@ -130,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` | @@ -141,16 +147,34 @@ 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` | +| `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` | | `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 +182,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 @@ -208,7 +235,15 @@ aggregate is. | `No_Per_Vertical_Folders` | [ADR-0018 § Architecture tests](../decisions/0018-tenant-driven-customization-model.md) | `No_Source_Folder_Named_Verticals` | ADR-0018 is Accepted and is not rewritten; the mapping lives here for the same reason -ADR-0017's spellings do. Every mutable carrier is corrected in place. +ADR-0017's spellings do. The **mutable** carriers — this catalogue, the standards, the +skills — carry the canonical names. + +ADR-0018's own body keeps the superseded spellings, and under +[ADR-0041](../decisions/0041-correcting-false-statements-in-accepted-adrs.md) it must: +those names were canonicalized *after* ADR-0018 was accepted, so they were true when +they entered the record and are stale now, which is history rather than error. If the +drift ever needs to be visible in ADR-0018 itself, the instrument is a dated Amendment +or an inline erratum — never a rewrite. The reconciliation is a [Phase 02a Packet 10](../roadmap/phase-02a-kernel-tenancy.md) deliverable: the canonical names go green in CI and the superseded spellings disappear @@ -621,10 +656,145 @@ 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 + +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** 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 (Amendments 1 and 2); + [05-database.md § Concurrency](05-database.md). +- **Type:** xUnit + EF model inspection. **Kind:** structural. +- **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`). + +#### `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 + 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. +- **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` + +- **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 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`'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, + `LearnStack.Tests.Architecture`, `PersistenceConventionTests`). + +#### `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 +804,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`) @@ -698,8 +889,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` @@ -711,14 +907,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` diff --git a/docs/standards/README.md b/docs/standards/README.md index 45c41653..d59020d5 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. | -| 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. | +| 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, three 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. @@ -109,7 +110,7 @@ to a reviewer than twenty-two identical labels. | Document type | Purpose | |---------------|---------| -| ADR (`docs/decisions/`) | A one-time decision with status, context, decision, consequences. Immutable history. | +| ADR (`docs/decisions/`) | A one-time decision with status, context, decision, consequences. Immutable history, corrected only by the two bounded mechanisms in [13-documentation.md § Correcting and Amending ADRs](13-documentation.md). | | Standard (`docs/standards/`) | An ongoing rule that the team applies day to day. Editable as the team learns. | When a standard is established, an ADR records the moment of adoption. The ADR then points at the standard for the living detail. diff --git a/infra/compose/README.md b/infra/compose/README.md index eabd2ada..a6a1dd5c 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 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 +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/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..68707614 --- /dev/null +++ b/infra/compose/postgres-init/02-create-roles.sql @@ -0,0 +1,144 @@ +-- 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 + +-- 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.) +-- 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; + +-- 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 +-- 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). + +-- 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 (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. +-- +-- :"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; + +-- 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. diff --git a/scripts/connection-string.awk b/scripts/connection-string.awk new file mode 100644 index 00000000..810db1ba --- /dev/null +++ b/scripts/connection-string.awk @@ -0,0 +1,158 @@ +# 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. +# +# ── 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 { + 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 +} + +{ + input = input (NR > 1 ? "\n" : "") $0 +} + +END { + count = split_fields(input, fields) + + if (field == "user") { + for (i = 1; i <= count; i++) { + if (normalize_key(fields[i]) in user) { + print unquote(fields[i]) "\n" + exit + } + } + + exit + } + + for (i = 1; i <= count; i++) { + if (i > 1) { + print ";" + } + + 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 + } + + # 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 + } +} 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