Skip to content

Commit 801d2f4

Browse files
authored
Merge pull request #15 from HodeTech/development
feat(tenancy): resolve tenants by host, provision them, seed two
2 parents b342c11 + 4ef2e17 commit 801d2f4

199 files changed

Lines changed: 20730 additions & 1170 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.claude/skills/add-architecture-test/SKILL.md‎

Lines changed: 81 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -110,22 +110,35 @@ Patterns to follow:
110110

111111
### Step 4: Common architecture-test families
112112

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

115116
| File | What it covers |
116117
|------|----------------|
117-
| `ModuleDependencyTests.cs` | Cross-module reference bans, contracts-only access. |
118-
| `TenantIsolationTests.cs` | `[TenantOwned]` + filter + RLS, `[OrganizationScoped]` + org RLS. |
119-
| `EventBusTests.cs` | Topic naming, integration-event base, inbox guard usage. |
120-
| `AuditTests.cs` | `AuditEntry` inheritance, no direct `audit_log` writes. |
121-
| `EntitlementTests.cs` | Plan-projected vs tenant-flag separation, FeatureKey registry. |
122-
| `PermissionTests.cs` | Closed action set, scope correctness, denied-test presence. |
123-
| `HubContractTests.cs` | No direct Hub-URL references; Hub clients only inside the named adapters (ADR-0034). |
124-
| `DomainGenericTests.cs` | `Core_Modules_HaveNo_DomainSpecific_Names`, no `Verticals/`. |
125-
| `DaprDirectInjectionTests.cs` | No `IConnectionMultiplexer` / `KafkaProducer` / `VaultClient` in modules. |
126-
| `ConventionTests.cs` | Strongly-typed ids in commands, validator pairing, etc. |
127-
128-
If your rule doesn't fit any file, create a new file with a focused name.
118+
| `ModuleDependencyTests.cs` | Dependency direction between module packages, plus a planted-violation meta test that proves the scanner still detects one. |
119+
| `PersistenceConventionTests.cs` | `row_version` mapping, ambient-unit-of-work enlistment, the Docker trait, and the `migrate` recipe's chain coverage and credential redaction. |
120+
| `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. |
121+
| `TenantScopingTests.cs` | The correspondence between the `[TenantOwned]` / `[OrganizationScoped]` markers, the EF global query filters, and the Row Level Security policies. |
122+
| `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. |
123+
| `ApiConventionTests.cs` | Live majors, forwarded headers, required `Deployment:Mode`, unversioned route prefixes. |
124+
| `CrossCuttingFoundationTests.cs` | Pipeline order, `Result<T>` returns, topic naming, and the direct-reference bans (Sentry, `DeploymentMode`, `IEventBus`, provider SDK exceptions). |
125+
| `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. |
126+
| `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. |
127+
| `RepositoryLayoutTests.cs` | `No_Source_Folder_Named_Verticals` and the single-frontend-app rule. |
128+
129+
Rules for surfaces no file covers yet — audit, permissions, entitlement, event bus,
130+
Hub contract — are **Registered** in
131+
[the catalogue](../../../docs/standards/21-architecture-tests-catalogue.md) against
132+
the phase that ships the code they inspect. Check its Status line before assuming a
133+
net is under you, and create a new file only when your rule's subject is not one of
134+
the ten above.
135+
136+
> **The tenancy rules live in three files, and the split is by subject, not by ADR.**
137+
> All three cite ADR-0036, so "put it with the other ADR-0036 rules" is not a usable
138+
> instruction. Ask what the rule is *about*: the request edge and what may be read from
139+
> it (`TenancyConventionTests`), the marker-to-filter-to-policy correspondence
140+
> (`TenantScopingTests`), or the construction and writing of the context itself
141+
> (`TenantContextConstructionTests`).
129142
130143
### Step 5: Stability of the test
131144

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

143156
```csharp
157+
/// <summary>A migration-scan sketch. NOT the shipped rule — see the note below.</summary>
144158
[Fact]
145-
public void Every_TenantOwned_Table_HasRls_With_AppTenantId()
159+
public void Migration_Files_Creating_Tenant_Owned_Tables_Carry_A_Policy_Block()
146160
{
147161
// RepositoryPaths.BackendSrc() — the shipped helper. A relative "backend/src"
148162
// is resolved against the TEST HOST's working directory (bin/Debug/net10.0),
@@ -151,32 +165,75 @@ public void Every_TenantOwned_Table_HasRls_With_AppTenantId()
151165
var migrationFiles = Directory
152166
.GetFiles(RepositoryPaths.BackendSrc(), "*.cs", SearchOption.AllDirectories)
153167
.Where(f => f.Contains($"{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}"))
168+
.Where(f => !f.EndsWith(".Designer.cs", StringComparison.Ordinal))
154169
.ToList();
155170

156-
// The guard that makes the emptiness observable. Without it this test passes
157-
// before a single migration exists and keeps passing if the path ever breaks.
171+
// Guard one: the path resolved and found files. Necessary, and on its own
172+
// not sufficient — see guard two.
158173
Assert.NotEmpty(migrationFiles);
159174

160-
foreach (var file in migrationFiles)
175+
var tenantOwned = migrationFiles
176+
.Select(f => (File: f, Content: File.ReadAllText(f)))
177+
// Both tokens, because the two shipped chains use one each. EF writes the
178+
// tenancy chain through migrationBuilder.CreateTable(name: "…"): measured,
179+
// "CREATE TABLE" occurs ZERO times in 20260828092437_create_tenancy_schema.cs,
180+
// which creates eight tables. The platform chain writes outbox_messages and
181+
// idempotency_keys through migrationBuilder.Sql("CREATE TABLE …") because
182+
// neither is an EF entity: "CreateTable(" occurs ZERO times in
183+
// 20260828085701_create_platform_infrastructure_tables.cs. Either token alone
184+
// classifies exactly one of the two chains, so the predicate is their union.
185+
.Where(x => (x.Content.Contains("CreateTable(") || x.Content.Contains("CREATE TABLE"))
186+
&& x.Content.Contains("tenant_id"))
187+
.ToList();
188+
189+
// Guard two, and the reason this test is worth landing: it asserts on what the
190+
// scan CLASSIFIED, not on what it read. A detection predicate that matches
191+
// nothing runs the loop zero times and reports green over the exact migrations
192+
// the rule exists to cover, past a NotEmpty guard on the file list. It catches
193+
// an EMPTY classification, not a PARTIAL one: either token on its own leaves
194+
// this assertion green while a whole shipped chain goes unscanned, which is
195+
// why the predicate above is a union.
196+
Assert.NotEmpty(tenantOwned);
197+
198+
foreach (var (file, content) in tenantOwned)
161199
{
162-
var content = File.ReadAllText(file);
163-
if (content.Contains("CREATE TABLE") && content.Contains("tenant_id"))
164-
{
165-
Assert.Contains("ENABLE ROW LEVEL SECURITY", content);
200+
Assert.True(
201+
content.Contains("ENABLE ROW LEVEL SECURITY")
166202
// FORCE is the half that matters: without it the table owner bypasses
167203
// its own policy and the whole layer is inert while ENABLE stays green.
168204
// Matched as a regex because the canonical template writes two spaces.
169-
Assert.Matches(@"FORCE\s+ROW LEVEL SECURITY", content);
205+
&& Regex.IsMatch(content, @"FORCE\s+ROW LEVEL SECURITY")
170206
// Must match the canonical template's exact shape. A bare
171207
// current_setting('app.tenant_id') assertion FAILS against every
172208
// correct migration and PASSES against the superseded one-argument
173209
// form — see ADR-0003 Amendment 3 and 05-database.md.
174-
Assert.Contains("NULLIF(current_setting('app.tenant_id', true), '')", content);
175-
}
210+
&& content.Contains("NULLIF(current_setting('app.tenant_id', true), '')"),
211+
$"{Path.GetFileName(file)} creates a tenant-owned table without the "
212+
+ "canonical policy block. Fix: copy it from docs/standards/05-database.md "
213+
+ "§ Tenant-Owned and Organization-Scoped Tables — that file is the only "
214+
+ "place the template exists.");
176215
}
177216
}
178217
```
179218

219+
**This sketch is deliberately not the canonical rule, and does not carry its name.**
220+
`Every_TenantOwned_Entity_HasFilterAndRlsPolicy` ships in
221+
`LearnStack.Tests.Architecture/TenantScopingTests.cs` and verifies **per entity**
222+
against the EF model — every marked type has a filter, and the migration carries that
223+
table's policy. The scan above is file-granular: delete one table's policy block from a
224+
migration that creates eight and it stays green, because a sibling table's block
225+
satisfies the `Contains`. Read it as an illustration of the mechanics — path resolution,
226+
the two `CREATE TABLE` spellings, the empty-classification guard — not as a rule to
227+
copy.
228+
229+
File granularity is what makes the predicate above safe *as an illustration*: the two
230+
**table classes** that key their policy on something else — `tenants` on `id`,
231+
`platform_host_to_tenant` on `app.resolving_host` — ship in a file that also creates
232+
ordinary tenant-owned tables, so the file-level `tenant_id` assertion holds. A per-table
233+
version needs the table classes from
234+
[Database Standards § Table classes](../../../docs/standards/05-database.md), which is
235+
what the shipped rule uses.
236+
180237
### Step 7: Test the test
181238

182239
Before merging:

‎.claude/skills/add-audit-coverage/SKILL.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -237,10 +237,12 @@ public async Task User_NationalId_isRedacted_In_AuditSnapshot()
237237

238238
- `dotnet build` and `dotnet test` pass.
239239
- Architecture tests:
240-
- `Module_<Name>_HasAuditMatrix` (the module's `audit.md` exists).
240+
- `Every_Module_Has_An_AuditCoverage_Matrix` (the module's `audit.md` exists) —
241+
Registered, backfilled in Packet 9.
241242
- `Modules_Do_Not_Write_AuditLog_Directly` (no `IAuditStore.WriteAsync` call from
242-
outside the audit infrastructure).
243-
- `Every_MustAudit_Operation_HasMatrixEntry`.
243+
outside the audit infrastructure) — Registered, Packet 10.
244+
- `Every_TenantOwned_Command_HasAuditCoverage` — Registered, backfilled in
245+
Packet 9.
244246
- An integration test demonstrates the new entry appears in `audit_log` with the
245247
right `operation`, `actor`, `before`, `after`, and any `[PiiSensitive]` fields
246248
redacted.
@@ -261,8 +263,8 @@ public async Task User_NationalId_isRedacted_In_AuditSnapshot()
261263
method, and `learnstack_app` holds no `UPDATE` privilege on `audit_log`.
262264
- **Truncating snapshots silently.** If a `before/after` JSON is too large, store
263265
an external pointer (`audit_blob_id`); never an empty object.
264-
- **Skipping the matrix update.** `Module_<Name>_HasAuditMatrix` will fail; CI
265-
rejects.
266+
- **Skipping the matrix update.** `Every_Module_Has_An_AuditCoverage_Matrix` will
267+
fail once Packet 9 backfills it; until then review is the only gate.
266268
- **Auditing a `read` for noise.** `read-sensitive` is the only read class that
267269
should be audited; broad read auditing creates noise that hides real signals.
268270
- **Tenants relaxing MUST.** Forbidden by the catalogue API. Calling

‎.claude/skills/add-backend-module/SKILL.md‎

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,18 @@ flowchart LR
8282
Application --> Application.Contracts
8383
Application -. depends on .-> OtherModule.Application.Contracts
8484
Infrastructure --> Application
85+
Infrastructure --> CoreInfrastructure[LearnStack.Infrastructure core]
8586
Infrastructure --> ProviderSDKs
8687
Application.Contracts --> SharedKernel
8788
```
8889

90+
`Infrastructure → LearnStack.Infrastructure` (core) is required, not optional: it
91+
carries `TenantScopedDbContext`, the base your module's `DbContext` derives from in
92+
Step 4. The seam lives there rather than in `SharedKernel` because it calls EF
93+
model-building APIs, and SharedKernel's EF reference is sanctioned for Vogen-emitted
94+
converters only. Omit the reference and Step 4's sample does not compile — the base
95+
clause is the first thing that fails.
96+
8997
Forbidden references (architecture test will catch them):
9098

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

163171
```csharp
172+
// `ModuleDbContextRegistration` builds every module context with
173+
// `ActivatorUtilities.CreateInstance<TContext>(provider, options)`, which passes the
174+
// options explicitly and resolves every other constructor parameter from DI — which
175+
// is how the accessor below arrives. `Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork`
176+
// still forbids registering the context any other way. This is the shape the one
177+
// shipped context carries.
164178
public sealed class <Name>DbContext(
165-
DbContextOptions<<Name>DbContext> options,
166-
ITenantContext tenantContext,
167-
IPublisher publisher)
168-
: DbContext(options)
179+
DbContextOptions<<Name>DbContext> options, ITenantContextAccessor accessor)
180+
: TenantScopedDbContext(options, accessor)
169181
{
182+
// The base owns the two members the filters close over and applies one to
183+
// every entity implementing ITenantOwned / IOrganizationScoped. Do not write
184+
// a filter here, and never from an IEntityTypeConfiguration: a configuration
185+
// reached by ApplyConfigurationsFromAssembly cannot close over the context
186+
// instance, and a filter whose closure root is anything else is constant-
187+
// folded into EF's cached model as a SQL literal. There is no
188+
// TenantQueryFilterConvention either; that type has never existed.
189+
//
190+
// The accessor rather than an injected ITenantContext: that contract is
191+
// registered transient and resolved from this same accessor, so a context
192+
// holding one freezes whatever the accessor held at construction.
193+
170194
protected override void OnModelCreating(ModelBuilder modelBuilder)
171195
{
172-
// Tenant + Organization query filters are applied PER ENTITY in its
173-
// IEntityTypeConfiguration. There is no TenantQueryFilterConvention —
174-
// that type has never existed. Every_TenantOwned_Entity_HasFilterAndRlsPolicy
175-
// is what WILL make a forgotten filter fail — it is registered in the
176-
// catalogue and implemented in Phase 02a Packet 7, not before.
177196
modelBuilder.ApplyConfigurationsFromAssembly(typeof(<Name>DbContext).Assembly);
197+
198+
// AFTER the configurations, so every entity type is in the model when the
199+
// base sweeps it. Forgetting this call loses every filter, silently.
200+
base.OnModelCreating(modelBuilder);
178201
}
179202
}
180203
```
181204

205+
Packet 7 step 3 settled how the accessor arrives: `ModuleDbContextRegistration`
206+
switched to `ActivatorUtilities.CreateInstance<TContext>(provider, options)`, so DI
207+
resolves it. The **accessor** and not an injected `ITenantContext`, and that is the
208+
load-bearing half — the context contract is registered transient and resolved from
209+
this same accessor, so a context holding one freezes whatever the accessor held at
210+
construction and never moves again. Measured: a context built under tenant A kept
211+
filtering to A after the accessor moved to B.
212+
182213
Per [05-database.md](../../../docs/standards/05-database.md), one `DbContext` per
183214
module — not one global.
184215

216+
`ApplyConfigurationsFromAssembly` also **silently skips** a configuration class
217+
that has constructor arguments — no exception, no log, the entity mapped by
218+
convention with no filter at all — so `<Name>Configuration(ITenantContext ctx)`
219+
disappears rather than failing. That is the second reason the filter is not a
220+
configuration's job. See
221+
[add-tenant-owned-entity Step 2](../add-tenant-owned-entity/SKILL.md).
222+
185223
### Step 5: Architecture test fixture
186224

187225
The dependency-direction and cross-module rules live in
188-
`backend/tests/LearnStack.Tests.Architecture/ModuleDependencyTests.cs` and are
189-
**scanned**, not listed — a new module needs no edit there. What is still owed is
226+
`backend/tests/LearnStack.Tests.Architecture/ModuleDependencyTests.cs`. Both are
227+
`[Theory]`-driven from the literal `ModuleNames` array in that file, not scanned —
228+
**add `<Name>` to that array**. Until you do, the new module's `Domain` assembly is
229+
never inspected and both rules pass vacuously. What is still owed is
190230
`Every_Module_Has_An_AuditCoverage_Matrix`, registered in
191231
[21-architecture-tests-catalogue.md](../../../docs/standards/21-architecture-tests-catalogue.md)
192232
and **awaiting backfill in Packet 9** with the audit catalogue it reads. Until it
@@ -231,9 +271,9 @@ See [add-ef-migration](../add-ef-migration/SKILL.md) for migration conventions
231271
## Validation
232272

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

0 commit comments

Comments
 (0)