Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
f8e0aa6
docs: correct the corpus Packet 7 implements against
cemililik Sep 1, 2026
53a9a17
docs: close what the Packet 7 corpus review found
cemililik Sep 1, 2026
a487e83
docs: name the phase that can actually carry app.scope
cemililik Sep 1, 2026
54a275e
feat(kernel): type the tenant context's identifiers
cemililik Sep 1, 2026
a6ac128
fix(kernel): pin the typed-id conversion where nothing was watching
cemililik Sep 1, 2026
06c22aa
test(kernel): kill the mutants the typed-id guards had left alive
cemililik Sep 1, 2026
076398a
feat(tenancy): the marker attributes and the EF query filters
cemililik Sep 1, 2026
e3483a6
fix(tenancy): give the self-keyed class its filter, and the rules teeth
cemililik Sep 1, 2026
9612acd
fix(tenancy): let the query filters follow the ambient tenant
cemililik Sep 1, 2026
4af3317
fix(tenancy): close the rest of what the Step 3 review reached
cemililik Sep 1, 2026
40372db
fix(tenancy): close the Step 3 Sonnet round
cemililik Sep 1, 2026
4ab91bf
feat(tenancy): host resolution and classification
cemililik Sep 1, 2026
f437443
fix(tenancy): close the two blockers the Step 4 review measured
cemililik Sep 1, 2026
f25ad49
test(tenancy): make the Step 4 guards fail without the code they cover
cemililik Sep 2, 2026
0a7b567
feat(tenancy): build the tenant context from the reconciliation matrix
cemililik Sep 2, 2026
6c4dff9
fix(tenancy): correct the context edge the Step 5 review measured
cemililik Sep 2, 2026
9df48d6
docs(tenancy): correct what the factory covers; pin what nothing held
cemililik Sep 2, 2026
4b5a610
feat(tenancy): enforce the authority ceiling at pipeline step 4
cemililik Sep 2, 2026
ae16181
fix(tenancy): sweep the code the markers will live in
cemililik Sep 2, 2026
1589eab
docs(tenancy): draw the line indistinguishability actually holds at
cemililik Sep 2, 2026
4d597a0
feat(tenancy): the one sanctioned path to a cross-tenant connection
cemililik Sep 2, 2026
ba12b05
fix(tenancy): always return the bypass connection; fence a spent handle
cemililik Sep 2, 2026
a41853e
fix(tenancy): close the credential read the rules could not see
cemililik Sep 2, 2026
eb64b47
feat(tenancy): make an unannounced transaction fail loudly
cemililik Sep 2, 2026
ca50a28
fix(tenancy): count the setters, and guard the arms nothing reached
cemililik Sep 2, 2026
fad94b2
fix(tenancy): assert the guard's let-through half; correct Amendment 5
cemililik Sep 2, 2026
36db879
feat(tenancy): let a provisioning request announce the tenant it creates
cemililik Sep 3, 2026
d29cc84
feat(tenancy): settle the aggregate boundary Packet 6 left open
cemililik Sep 3, 2026
12866b2
feat(tenancy): provision a tenant and its default organization
cemililik Sep 3, 2026
4ac783f
docs(tenancy): correct provisioning's shape and the rule that guards it
cemililik Sep 3, 2026
31c28a6
docs(tenancy): correct what refuses a zeroed provisioning actor
cemililik Sep 3, 2026
60a3e0f
fix(tenancy): answer a refused provisioning instead of crashing on it
cemililik Sep 3, 2026
91db848
fix(tenancy): keep a provisioning conflict a 409 through the status map
cemililik Sep 3, 2026
1646d72
feat(tenancy): seed two demo tenants through the real command path
cemililik Sep 3, 2026
24260ea
fix(tenancy): make the seed reachable, checked, and answerable
cemililik Sep 3, 2026
b8f723f
fix(tenancy): stop the seeder reading a refusal as "already seeded"
cemililik Sep 3, 2026
4182f13
test(tenancy): run the five isolation cases through a real request
cemililik Sep 3, 2026
af705d9
fix(tenancy): make the isolation cases assert what their names claim
cemililik Sep 3, 2026
f1f6314
fix(kernel): keep ADR-0037's promise and prove the reset case
cemililik Sep 3, 2026
e1ed969
docs(roadmap): close Packet 7 with the record of what it got wrong
cemililik Sep 3, 2026
150784a
fix(security): close three credential and transaction gaps
cemililik Sep 3, 2026
da0a029
docs(decisions): put three ADR edits back inside their own rules
cemililik Sep 3, 2026
019d057
fix(tenancy): read the trust bit and refuse unassigned ids
cemililik Sep 3, 2026
03dbf0f
fix(tenancy): verify a conflict is ours; default the first locale
cemililik Sep 3, 2026
6172a8b
docs: stop four documents claiming more than the code does
cemililik Sep 3, 2026
1367800
fix(tenancy): detach what the database refused; bound the flights
cemililik Sep 3, 2026
44b2c9e
fix(tenancy): promote a default locale in the order the index requires
cemililik Sep 3, 2026
cd15f22
fix(tenancy): invalidate the answer that matters; follow live majors
cemililik Sep 3, 2026
97fabdd
docs: correct three statements the code contradicts
cemililik Sep 3, 2026
6c0551d
build(hooks): check the commit subject before CI has to
cemililik Sep 3, 2026
55577bf
fix(tenancy): refuse a default nobody serves; close two rule escapes
cemililik Sep 3, 2026
cf20155
test(kernel): pin pipeline idempotency; drain both pipes before waiting
cemililik Sep 3, 2026
27740d4
chore(tests): drop two dependencies nothing referenced, correct two docs
cemililik Sep 3, 2026
b6f32c4
fix(database): keep an org-scoped session out of tenant-wide rows
cemililik Sep 3, 2026
4ef2e17
docs(kernel): name the two idempotency limits that do not bound memory
cemililik Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 81 additions & 24 deletions .claude/skills/add-architecture-test/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,22 +110,35 @@ Patterns to follow:

### Step 4: Common architecture-test families

The current set lives across these files; add yours to the right one:
**The shipped set is ten files, not a family per topic.** Add yours to the one whose
subject it shares:

| File | What it covers |
|------|----------------|
| `ModuleDependencyTests.cs` | Cross-module reference bans, contracts-only access. |
| `TenantIsolationTests.cs` | `[TenantOwned]` + filter + RLS, `[OrganizationScoped]` + org RLS. |
| `EventBusTests.cs` | Topic naming, integration-event base, inbox guard usage. |
| `AuditTests.cs` | `AuditEntry` inheritance, no direct `audit_log` writes. |
| `EntitlementTests.cs` | Plan-projected vs tenant-flag separation, FeatureKey registry. |
| `PermissionTests.cs` | Closed action set, scope correctness, denied-test presence. |
| `HubContractTests.cs` | No direct Hub-URL references; Hub clients only inside the named adapters (ADR-0034). |
| `DomainGenericTests.cs` | `Core_Modules_HaveNo_DomainSpecific_Names`, no `Verticals/`. |
| `DaprDirectInjectionTests.cs` | No `IConnectionMultiplexer` / `KafkaProducer` / `VaultClient` in modules. |
| `ConventionTests.cs` | Strongly-typed ids in commands, validator pairing, etc. |

If your rule doesn't fit any file, create a new file with a focused name.
| `ModuleDependencyTests.cs` | Dependency direction between module packages, plus a planted-violation meta test that proves the scanner still detects one. |
| `PersistenceConventionTests.cs` | `row_version` mapping, ambient-unit-of-work enlistment, the Docker trait, and the `migrate` recipe's chain coverage and credential redaction. |
| `TenancyConventionTests.cs` | The ADR-0036 **request-edge** rules — what may read a host, where the effective host and `app.resolving_host` are computed, and the assertion budget's independence from `ICacheService`. Source scans, because three of the four banned inputs appear only as string literals. |
| `TenantScopingTests.cs` | The correspondence between the `[TenantOwned]` / `[OrganizationScoped]` markers, the EF global query filters, and the Row Level Security policies. |
| `TenantContextConstructionTests.cs` | How a tenant context comes into existence and who may write it: the factory's single entry point, the constructor's one call site, the enumerated accessor writers, and the composite-key organization read. |
| `ApiConventionTests.cs` | Live majors, forwarded headers, required `Deployment:Mode`, unversioned route prefixes. |
| `CrossCuttingFoundationTests.cs` | Pipeline order, `Result<T>` returns, topic naming, and the direct-reference bans (Sentry, `DeploymentMode`, `IEventBus`, provider SDK exceptions). |
| `RequestSurfaceTests.cs` | What the step-4 authority ceiling admits: the two request markers, their permitted sets, the shape of the attributes themselves, and the ban on request shapes MediatR runs with no pipeline. |
| `PlatformAdminScopeConventionTests.cs` | The single sanctioned `BYPASSRLS` path: who may resolve the keyed platform data source, where connection strings are read, the entry gate, and what the scope must not touch. |
| `RepositoryLayoutTests.cs` | `No_Source_Folder_Named_Verticals` and the single-frontend-app rule. |

Rules for surfaces no file covers yet — audit, permissions, entitlement, event bus,
Hub contract — are **Registered** in
[the catalogue](../../../docs/standards/21-architecture-tests-catalogue.md) against
the phase that ships the code they inspect. Check its Status line before assuming a
net is under you, and create a new file only when your rule's subject is not one of
the ten above.

> **The tenancy rules live in three files, and the split is by subject, not by ADR.**
> All three cite ADR-0036, so "put it with the other ADR-0036 rules" is not a usable
> instruction. Ask what the rule is *about*: the request edge and what may be read from
> it (`TenancyConventionTests`), the marker-to-filter-to-policy correspondence
> (`TenantScopingTests`), or the construction and writing of the context itself
> (`TenantContextConstructionTests`).

### Step 5: Stability of the test

Expand All @@ -141,8 +154,9 @@ Architecture tests are **non-skippable**. That means:
When the rule is about migration content (RLS, partition):

```csharp
/// <summary>A migration-scan sketch. NOT the shipped rule — see the note below.</summary>
[Fact]
public void Every_TenantOwned_Table_HasRls_With_AppTenantId()
public void Migration_Files_Creating_Tenant_Owned_Tables_Carry_A_Policy_Block()
{
// RepositoryPaths.BackendSrc() — the shipped helper. A relative "backend/src"
// is resolved against the TEST HOST's working directory (bin/Debug/net10.0),
Expand All @@ -151,32 +165,75 @@ public void Every_TenantOwned_Table_HasRls_With_AppTenantId()
var migrationFiles = Directory
.GetFiles(RepositoryPaths.BackendSrc(), "*.cs", SearchOption.AllDirectories)
.Where(f => f.Contains($"{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}"))
.Where(f => !f.EndsWith(".Designer.cs", StringComparison.Ordinal))
.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.
// Guard one: the path resolved and found files. Necessary, and on its own
// not sufficient — see guard two.
Assert.NotEmpty(migrationFiles);

foreach (var file in migrationFiles)
var tenantOwned = migrationFiles
.Select(f => (File: f, Content: File.ReadAllText(f)))
// Both tokens, because the two shipped chains use one each. EF writes the
// tenancy chain through migrationBuilder.CreateTable(name: "…"): measured,
// "CREATE TABLE" occurs ZERO times in 20260828092437_create_tenancy_schema.cs,
// which creates eight tables. The platform chain writes outbox_messages and
// idempotency_keys through migrationBuilder.Sql("CREATE TABLE …") because
// neither is an EF entity: "CreateTable(" occurs ZERO times in
// 20260828085701_create_platform_infrastructure_tables.cs. Either token alone
// classifies exactly one of the two chains, so the predicate is their union.
.Where(x => (x.Content.Contains("CreateTable(") || x.Content.Contains("CREATE TABLE"))
&& x.Content.Contains("tenant_id"))
.ToList();

// Guard two, and the reason this test is worth landing: it asserts on what the
// scan CLASSIFIED, not on what it read. A detection predicate that matches
// nothing runs the loop zero times and reports green over the exact migrations
// the rule exists to cover, past a NotEmpty guard on the file list. It catches
// an EMPTY classification, not a PARTIAL one: either token on its own leaves
// this assertion green while a whole shipped chain goes unscanned, which is
// why the predicate above is a union.
Assert.NotEmpty(tenantOwned);

foreach (var (file, content) in tenantOwned)
{
var content = File.ReadAllText(file);
if (content.Contains("CREATE TABLE") && content.Contains("tenant_id"))
{
Assert.Contains("ENABLE ROW LEVEL SECURITY", content);
Assert.True(
content.Contains("ENABLE ROW LEVEL SECURITY")
// FORCE is the half that matters: without it the table owner bypasses
// its own policy and the whole layer is inert while ENABLE stays green.
// Matched as a regex because the canonical template writes two spaces.
Assert.Matches(@"FORCE\s+ROW LEVEL SECURITY", content);
&& Regex.IsMatch(content, @"FORCE\s+ROW LEVEL SECURITY")
// Must match the canonical template's exact shape. A bare
// current_setting('app.tenant_id') assertion FAILS against every
// correct migration and PASSES against the superseded one-argument
// form — see ADR-0003 Amendment 3 and 05-database.md.
Assert.Contains("NULLIF(current_setting('app.tenant_id', true), '')", content);
}
&& content.Contains("NULLIF(current_setting('app.tenant_id', true), '')"),
$"{Path.GetFileName(file)} creates a tenant-owned table without the "
+ "canonical policy block. Fix: copy it from docs/standards/05-database.md "
+ "§ Tenant-Owned and Organization-Scoped Tables — that file is the only "
+ "place the template exists.");
}
}
```

**This sketch is deliberately not the canonical rule, and does not carry its name.**
`Every_TenantOwned_Entity_HasFilterAndRlsPolicy` ships in
`LearnStack.Tests.Architecture/TenantScopingTests.cs` and verifies **per entity**
against the EF model — every marked type has a filter, and the migration carries that
table's policy. The scan above is file-granular: delete one table's policy block from a
migration that creates eight and it stays green, because a sibling table's block
satisfies the `Contains`. Read it as an illustration of the mechanics — path resolution,
the two `CREATE TABLE` spellings, the empty-classification guard — not as a rule to
copy.

File granularity is what makes the predicate above safe *as an illustration*: the two
**table classes** that key their policy on something else — `tenants` on `id`,
`platform_host_to_tenant` on `app.resolving_host` — ship in a file that also creates
ordinary tenant-owned tables, so the file-level `tenant_id` assertion holds. A per-table
version needs the table classes from
[Database Standards § Table classes](../../../docs/standards/05-database.md), which is
what the shipped rule uses.

### Step 7: Test the test

Before merging:
Expand Down
12 changes: 7 additions & 5 deletions .claude/skills/add-audit-coverage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,10 +237,12 @@ public async Task User_NationalId_isRedacted_In_AuditSnapshot()

- `dotnet build` and `dotnet test` pass.
- Architecture tests:
- `Module_<Name>_HasAuditMatrix` (the module's `audit.md` exists).
- `Every_Module_Has_An_AuditCoverage_Matrix` (the module's `audit.md` exists) —
Registered, backfilled in Packet 9.
- `Modules_Do_Not_Write_AuditLog_Directly` (no `IAuditStore.WriteAsync` call from
outside the audit infrastructure).
- `Every_MustAudit_Operation_HasMatrixEntry`.
outside the audit infrastructure) — Registered, Packet 10.
- `Every_TenantOwned_Command_HasAuditCoverage` — Registered, backfilled in
Packet 9.
- An integration test demonstrates the new entry appears in `audit_log` with the
right `operation`, `actor`, `before`, `after`, and any `[PiiSensitive]` fields
redacted.
Expand All @@ -261,8 +263,8 @@ public async Task User_NationalId_isRedacted_In_AuditSnapshot()
method, and `learnstack_app` holds no `UPDATE` privilege on `audit_log`.
- **Truncating snapshots silently.** If a `before/after` JSON is too large, store
an external pointer (`audit_blob_id`); never an empty object.
- **Skipping the matrix update.** `Module_<Name>_HasAuditMatrix` will fail; CI
rejects.
- **Skipping the matrix update.** `Every_Module_Has_An_AuditCoverage_Matrix` will
fail once Packet 9 backfills it; until then review is the only gate.
- **Auditing a `read` for noise.** `read-sensitive` is the only read class that
should be audited; broad read auditing creates noise that hides real signals.
- **Tenants relaxing MUST.** Forbidden by the catalogue API. Calling
Expand Down
73 changes: 57 additions & 16 deletions .claude/skills/add-backend-module/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,18 @@ flowchart LR
Application --> Application.Contracts
Application -. depends on .-> OtherModule.Application.Contracts
Infrastructure --> Application
Infrastructure --> CoreInfrastructure[LearnStack.Infrastructure core]
Infrastructure --> ProviderSDKs
Application.Contracts --> SharedKernel
```

`Infrastructure → LearnStack.Infrastructure` (core) is required, not optional: it
carries `TenantScopedDbContext`, the base your module's `DbContext` derives from in
Step 4. The seam lives there rather than in `SharedKernel` because it calls EF
model-building APIs, and SharedKernel's EF reference is sanctioned for Vogen-emitted
converters only. Omit the reference and Step 4's sample does not compile — the base
clause is the first thing that fails.

Forbidden references (architecture test will catch them):

- Domain → Application / Infrastructure
Expand Down Expand Up @@ -161,32 +169,64 @@ only three files under `backend/src` may mention `UseNpgsql` at all.
In `LearnStack.Modules.<Name>.Infrastructure/Persistence/<Name>DbContext.cs`:

```csharp
// `ModuleDbContextRegistration` builds every module context with
// `ActivatorUtilities.CreateInstance<TContext>(provider, options)`, which passes the
// options explicitly and resolves every other constructor parameter from DI — which
// is how the accessor below arrives. `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork`
// still forbids registering the context any other way. This is the shape the one
// shipped context carries.
public sealed class <Name>DbContext(
DbContextOptions<<Name>DbContext> options,
ITenantContext tenantContext,
IPublisher publisher)
: DbContext(options)
DbContextOptions<<Name>DbContext> options, ITenantContextAccessor accessor)
: TenantScopedDbContext(options, accessor)
{
// The base owns the two members the filters close over and applies one to
// every entity implementing ITenantOwned / IOrganizationScoped. Do not write
// a filter here, and never from an IEntityTypeConfiguration: a configuration
// reached by ApplyConfigurationsFromAssembly cannot close over the context
// instance, and a filter whose closure root is anything else is constant-
// folded into EF's cached model as a SQL literal. There is no
// TenantQueryFilterConvention either; that type has never existed.
//
// The accessor rather than an injected ITenantContext: that contract is
// registered transient and resolved from this same accessor, so a context
// holding one freezes whatever the accessor held at construction.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 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(<Name>DbContext).Assembly);

// AFTER the configurations, so every entity type is in the model when the
// base sweeps it. Forgetting this call loses every filter, silently.
base.OnModelCreating(modelBuilder);
}
}
```

Packet 7 step 3 settled how the accessor arrives: `ModuleDbContextRegistration`
switched to `ActivatorUtilities.CreateInstance<TContext>(provider, options)`, so DI
resolves it. The **accessor** and not an injected `ITenantContext`, and that is the
load-bearing half — the context contract is registered transient and resolved from
this same accessor, so a context holding one freezes whatever the accessor held at
construction and never moves again. Measured: a context built under tenant A kept
filtering to A after the accessor moved to B.

Per [05-database.md](../../../docs/standards/05-database.md), one `DbContext` per
module — not one global.

`ApplyConfigurationsFromAssembly` also **silently skips** a configuration class
that has constructor arguments — no exception, no log, the entity mapped by
convention with no filter at all — so `<Name>Configuration(ITenantContext ctx)`
disappears rather than failing. That is the second reason the filter is not a
configuration's job. See
[add-tenant-owned-entity Step 2](../add-tenant-owned-entity/SKILL.md).

### Step 5: Architecture test fixture

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
`backend/tests/LearnStack.Tests.Architecture/ModuleDependencyTests.cs`. Both are
`[Theory]`-driven from the literal `ModuleNames` array in that file, not scanned —
**add `<Name>` to that array**. Until you do, the new module's `Domain` assembly is
never inspected and both rules pass vacuously. 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
Expand Down Expand Up @@ -231,9 +271,9 @@ See [add-ef-migration](../add-ef-migration/SKILL.md) for migration conventions
## Validation

- `dotnet build` succeeds for all four projects.
- `LearnStack.Tests.Architecture` is green; specifically
`Module_<Name>_DependencyDirection_IsCorrect`,
`Module_<Name>_HasAuditMatrix`, `Module_<Name>_HasPermissionMatrix`.
- `LearnStack.Tests.Architecture` is green; specifically the two rules that
actually run, `ModuleDomain_DoesNotDependOn_OtherModuleDomain` and
`ModuleDomain_DoesNotDependOn_AnyApplicationOrInfrastructure`, for `<Name>`.
- `dotnet ef migrations script` for the module shows the expected baseline schema.
- The module appears in [03-module-boundaries.md](../../../docs/architecture/03-module-boundaries.md)
module map and in [docs/glossary.md](../../../docs/glossary.md) if it owns any
Expand All @@ -252,5 +292,6 @@ See [add-ef-migration](../add-ef-migration/SKILL.md) for migration conventions
reads go through repository contracts or read-model projections.
- **Forgetting the `IModule` registration in the composition root.** The module
builds but no handlers run; takes hours to diagnose.
- **Missing `docs/modules/<name>/` spec files.** The architecture tests
`Module_<Name>_HasAuditMatrix` / `_HasPermissionMatrix` fail; CI rejects the PR.
- **Missing `docs/modules/<name>/` spec files.** Nothing fails.
`Every_Module_Has_An_AuditCoverage_Matrix` is Registered against Packet 9 and
there is no permission-matrix rule at all, so review is the only gate until then.
Loading
Loading