Skip to content

Commit d246074

Browse files
authored
Merge pull request #19 from HodeTech/development
Phase 02a Packet 10: architecture tests green, and the phase exit
2 parents 42fee94 + a074643 commit d246074

113 files changed

Lines changed: 8540 additions & 1544 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: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ description: >
1414

1515
## Purpose
1616

17-
Move a rule from documentation prose into a CI-enforced check using NetArchTest /
18-
ArchUnitNET / Roslyn analyzers / migration scans
17+
Move a rule from documentation prose into a CI-enforced check — NetArchTest, reflection,
18+
an IL scan, a source or migration scan, or a Roslyn analyzer
1919
([01-architecture-standards.md § Architecture Tests](../../../docs/standards/01-architecture-standards.md)).
2020

2121
## When to use
@@ -40,7 +40,7 @@ ArchUnitNET / Roslyn analyzers / migration scans
4040
|-------|----------|-------------|
4141
| Rule | Yes | Plain-English statement of the invariant. |
4242
| Source | Yes | ADR / standard that defines the rule. |
43-
| Detection mechanism | Yes | NetArchTest / ArchUnitNET (project graph) / Roslyn (syntactic) / migration scan (SQL pattern). |
43+
| Detection mechanism | Yes | NetArchTest (type references) / reflection / Mono.Cecil (IL) / Roslyn (syntactic) / source or migration scan (text). |
4444
| Failure message | Yes | Actionable: tell the author what to do, not just what is wrong. |
4545

4646
## Workflow
@@ -57,21 +57,26 @@ them.
5757
| Mechanism | Use when |
5858
|-----------|----------|
5959
| **NetArchTest** | Project-graph reference bans, naming conventions on types / namespaces. |
60-
| **ArchUnitNET** | Slightly richer rules (constraints across assemblies). Pick when NetArchTest can't express it. |
61-
| **Roslyn analyzer** | Syntactic patterns inside code (`IgnoreQueryFilters` call, raw `Guid` in command, …). |
60+
| **Mono.Cecil (IL scan)** | What a method body does — a constructor called, a member read — which NetArchTest's type-level rules cannot express. It ships with NetArchTest; there is no ArchUnitNET in this repository. |
61+
| **Roslyn analyzer** | A syntactic pattern the build itself should fail on — `throw new DomainException(...)` outside an invariant guard is `LS0001`. |
6262
| **Migration scan** | SQL patterns in `.cs` migration files (RLS-enable, policy presence, partition declaration). |
6363
| **Reflection over conventions** | Marker-attribute presence + matching property / migration shape. |
6464
| **String / regex scan** | When all else fails — explicit "no file under `Verticals/`", "no `english.*` permission key". |
6565

6666
### Step 3: Author the test
6767

68-
In `backend/tests/LearnStack.Tests.Architecture/`:
68+
In `backend/tests/LearnStack.Tests.Architecture/`. The block below illustrates the
69+
shape and is **not** a rule to add: its subject ships as
70+
`ModuleDomain_DoesNotDependOn_OtherModuleDomain` in `ModuleDependencyTests.cs`, and a
71+
rule's name comes from
72+
[the catalogue](../../../docs/standards/21-architecture-tests-catalogue.md), never from
73+
an example.
6974

7075
```csharp
71-
public sealed class ModuleDependencyTests
76+
public sealed class ExampleBoundaryTests
7277
{
7378
[Fact]
74-
public void Modules_DoNot_Reference_OtherModule_Domain()
79+
public void Example_Module_Does_Not_Reference_Another_Modules_Domain()
7580
{
7681
// pseudo-code; replace `GetOwningModuleNamespace(...)` with the real
7782
// owning-namespace resolver helper that lives next to the test. The
@@ -110,32 +115,36 @@ Patterns to follow:
110115

111116
### Step 4: Common architecture-test families
112117

113-
**The shipped set is fourteen files, not a family per topic.** Add yours to the one whose
118+
**The shipped set is eighteen files, not a family per topic.** Add yours to the one whose
114119
subject it shares:
115120

116121
| File | What it covers |
117122
|------|----------------|
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. |
123+
| `ModuleDependencyTests.cs` | The dependency matrix — each module layer, and the core `Application`, an allow-list over its IL and its project references — the Domain's one EF Core exception for Vogen's emitted converters, plus a planted-violation meta test that proves the scanner still detects one. |
124+
| `PersistenceConventionTests.cs` | `row_version` mapping, ambient-unit-of-work enlistment and the ban on fanning out over its connection, the Docker trait, and the `migrate` recipe's chain coverage and credential redaction. |
125+
| `TenancyConventionTests.cs` | The ADR-0036 **request-edge** rules — what may read a host, where the effective host and `app.resolving_host` are computed, the assertion budget's independence from `ICacheService`, and the host resolver's lack of outbound dependencies — and who may set a session variable: the out-of-band setters' read-only transactions and the ban on setting `app.scope`. Mostly source scans, because most of the banned inputs are header names and SQL that appear only as string literals. |
126+
| `TenantScopingTests.cs` | The correspondence between the `[TenantOwned]` / `[OrganizationScoped]` markers, the EF global query filters, and the Row Level Security policies, down to the tenant term in both of a policy's clauses. |
122127
| `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. |
123128
| `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). |
129+
| `CrossCuttingFoundationTests.cs` | Pipeline order, `Result<T>` returns, topic naming, and the direct-reference bans (Sentry, `DeploymentMode`, `IEventBus`, the cache clients, the Hub, provider SDK exceptions). |
125130
| `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. |
126131
| `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. |
132+
| `RepositoryLayoutTests.cs` | `No_Source_Folder_Named_Verticals`, the single-frontend-app rule, and the commit-subject grammar — checked by running the `commit-msg` hook. |
128133
| `AggregateWriteTests.cs` | The one sanctioned cross-aggregate write, and the count of aggregate roots a handler's ports can write that keeps it at one. |
129134
| `CustomizationRegistryTests.cs` | The closed renderer registries that exist twice — in C# and in TypeScript — and stop the two drifting. |
130-
| `AuditConventionTests.cs` | The audit aggregate's shape, the closed-set columns against their `CHECK`s, append-only enforcement at the source level, and the `OperationType` table. |
135+
| `AuditConventionTests.cs` | The audit aggregate's shape, the closed-set columns against their `CHECK`s, append-only enforcement at the source level, the closed set of `audit_log` writers, and the `OperationType` table. |
136+
| `EntitlementConventionTests.cs` | The entitlement socket: which types may name `platform_entitlement_cache`'s entity, and whose SQL may touch its rows. |
137+
| `EntitlementKeyTests.cs` | The entitlement vocabulary: where a feature, limit or killswitch key may be spelled, and which keys the tenant's own flag table admits. |
138+
| `DomainModelTests.cs` | The shape every aggregate carries: identity equality declared once, and an aggregate root's id generated rather than hand-written. |
139+
| `DomainGenericityTests.cs` | The genericity guarantee: no name the platform ships — backend, database, audit slug, key, renderer or frontend — carries a domain term. |
131140
| `AuditCoverageTests.cs` | The catalogue ↔ matrix join, keyed on request types discovered from every backend assembly, in both directions — plus the module-has-a-matrix rule. |
132141

133-
Rules for surfaces no file covers yet — permissions, entitlement, event bus, Hub
134-
contract — are **Registered** in
142+
Rules for surfaces no file covers yet — permissions, the Hub adapters and endpoints — are
143+
**Registered** in
135144
[the catalogue](../../../docs/standards/21-architecture-tests-catalogue.md) against
136145
the phase that ships the code they inspect. Check its Status line before assuming a
137146
net is under you, and create a new file only when your rule's subject is not one of
138-
the fourteen above.
147+
the eighteen above.
139148

140149
> **The tenancy rules live in three files, and the split is by subject, not by ADR.**
141150
> All three cite ADR-0036, so "put it with the other ADR-0036 rules" is not a usable

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -492,10 +492,11 @@ passes even when every policy is inert. See
492492
Implemented (`AuditConventionTests`), and `Every_Module_With_An_Aggregate_Or_A_Request_Has_A_Matrix`
493493
— Implemented (`AuditCoverageTests`) — fails a module that ships an aggregate or a
494494
request with none.
495-
- `Modules_Do_Not_Write_AuditLog_Directly` (no module assembly outside
496-
`LearnStack.Modules.Audit.*` names `audit_log` or `AuditEntry`; the
497-
`LearnStack.SharedKernel.Audit` ports are explicitly out of scope) — Registered,
498-
Packet 10.
495+
- `Modules_Do_Not_Write_AuditLog_Directly` (the types that name `AuditEntry` are the
496+
entity, its configuration and `AuditDbContext`; the only SQL inserting into
497+
`audit_log` is `PostgresAuditStore`'s; no module but Audit names the table; the
498+
`LearnStack.SharedKernel.Audit` ports are explicitly out of scope) — Implemented
499+
(`AuditConventionTests`).
499500
- `No_Set_Based_Write_Bypasses_The_Audit_Capture` (no `ExecuteUpdate`,
500501
`ExecuteDelete` or `ExecuteSql*` in backend source — the capture sees only the change
501502
tracker, so the operation you are classifying must write through its aggregate and

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,9 +285,12 @@ See [add-ef-migration](../add-ef-migration/SKILL.md) for migration conventions
285285
## Validation
286286

287287
- `dotnet build` succeeds for all four projects.
288-
- `LearnStack.Tests.Architecture` is green; specifically the two rules that
289-
actually run, `ModuleDomain_DoesNotDependOn_OtherModuleDomain` and
290-
`ModuleDomain_DoesNotDependOn_AnyApplicationOrInfrastructure`, for `<Name>`.
288+
- `LearnStack.Tests.Architecture` is green; specifically the dependency matrix for
289+
`<Name>` — `ModuleDomain_DoesNotDependOn_OtherModuleDomain`,
290+
`ModuleDomain_DoesNotDependOn_AnyApplicationOrInfrastructure`,
291+
`ModuleApplication_References_Only_Its_Own_Layers_And_Other_Contracts`,
292+
`ModuleInfrastructure_References_Only_Its_Own_Layers_And_Core_Infrastructure` and
293+
`ModuleContracts_Reference_Only_SharedKernel`.
291294
- `dotnet ef migrations script` for the module shows the expected baseline schema.
292295
- The module appears in [03-module-boundaries.md](../../../docs/architecture/03-module-boundaries.md)
293296
module map and in [docs/glossary.md](../../../docs/glossary.md) if it owns any

‎.claude/skills/add-feature-key/SKILL.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -438,11 +438,13 @@ public async Task RefreshAsync_AppliesAnEqualGenerationPush()
438438
- `dotnet build` and `dotnet test` pass.
439439
- Architecture tests, canonical names from
440440
[21-architecture-tests-catalogue.md](../../../docs/standards/21-architecture-tests-catalogue.md):
441-
- `FeatureKey_AllReferences_AreInRegistry` (Roslyn) — every `IFeatureFlags` call
442-
references a registered key. There is no separate `LimitKey_*` spelling; this rule
443-
covers both key types.
441+
- `FeatureKey_AllReferences_AreInRegistry` (an IL scan) — every `FeatureKey`,
442+
`LimitKey` and `KillswitchKey` a call site names is a member of its registry. There
443+
is no separate `LimitKey_*` spelling; this one rule covers all three key types.
444444
- `PlanProjected_Keys_NotInTenantFlags` — plan-projected keys never appear in
445-
`tenant_feature_flags`.
445+
`tenant_feature_flags`. `Tenant.SetFeatureFlag` takes a `FeatureKey` and refuses every
446+
key whose descriptor is not `FeatureSource.TenantFlag`, so a new plan key needs no
447+
change here and a new tenant flag is admitted by its descriptor alone.
446448
- `Modules_Do_Not_Read_Entitlement_Cache_Directly` — the only sanctioned reader **and**
447449
writer of `platform_entitlement_cache` is an `IEntitlementProvider` implementation;
448450
no module, Tenancy included, may query it.

‎.claude/skills/add-integration-event/SKILL.md‎

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,13 @@ await outbox.EnqueueAsync(new EnrollmentCreatedIntegrationEventV1
110110
TenantId = tenantContext.TenantId.Value, // the envelope carries a Guid
111111
EnrollmentId = enrollment.Id.Value,
112112
LearnerId = request.LearnerId.Value,
113-
CourseVersionId = request.CourseVersionId.Value,
114-
CohortId = request.CohortId?.Value,
113+
// Three types, two of them the same. The contract carries a module-local id as a
114+
// `Guid` (add-mediatr-handler § Step 1) and this event declares one, so the request's
115+
// value assigns directly — `request.CourseVersionId.Value` does not compile, because a
116+
// `Guid` has no `Value`. The handler's own `courseVersionId` is the TYPED id it built
117+
// one layer in, so that spelling needs `courseVersionId.Value`.
118+
CourseVersionId = request.CourseVersionId,
119+
CohortId = request.CohortId,
115120
Source = request.Source.ToString().ToLowerInvariant(),
116121
}, cancellationToken);
117122

@@ -120,9 +125,12 @@ await db.SaveChangesAsync(cancellationToken); // atomic
120125

121126
Rules:
122127

123-
- `IOutbox.EnqueueAsync` enrolls in the **ambient** `DbContext`. Do not open a new
124-
transaction.
125-
- `SaveChangesAsync` commits the aggregate change and the outbox row together.
128+
- `IOutbox.EnqueueAsync` enlists in the **ambient unit of work** — the one connection and
129+
transaction `IUnitOfWork` owns ([ADR-0040](../../../docs/decisions/0040-ambient-unit-of-work.md)),
130+
not a module's `DbContext`. Do not open a second transaction.
131+
- `SaveChangesAsync` **flushes** the context; it does not commit. `TransactionBehavior` owns
132+
the commit boundary, so the aggregate change and the outbox row land together because they
133+
share that transaction — not because one `SaveChanges` wrote both.
126134

127135
### Step 3: Topic declaration
128136

@@ -178,10 +186,14 @@ public sealed class CreateAuditEntryOnEnrollmentCreated(
178186
Rules:
179187

180188
- **Every** `IIntegrationEventHandler` invokes `IInboxGuard.IsAlreadyProcessedAsync`
181-
before any business logic. Architecture test
182-
`Integration_Event_Handlers_Use_InboxGuard` enforces this.
183-
- `MarkAsProcessed` enrolls in the same `DbContext`; the inbox marker and the
184-
business write commit atomically.
189+
before any business logic. The architecture test
190+
`Integration_Event_Handlers_Use_InboxGuard` holds every handler to it from
191+
[Phase 02b](../../../docs/roadmap/phase-02b-events-auth.md), which ships the guard; it
192+
is registered, not running, until then.
193+
- `MarkAsProcessed` writes on the same **transaction** as the business write — the ambient
194+
one the transport opens per delivery through `IUnitOfWork`
195+
([ADR-0040](../../../docs/decisions/0040-ambient-unit-of-work.md)) — so the inbox marker
196+
and the business write commit atomically.
185197
- The **transport** — not middleware — restores tenant context from
186198
`@event.TenantId` before your handler runs, and puts the publisher's own back
187199
afterwards. Read it through `ITenantContext` as usual; don't read it from
@@ -232,10 +244,12 @@ Two tests minimum:
232244
## Validation
233245

234246
- `dotnet build` and `dotnet test` pass.
235-
- `LearnStack.Tests.Architecture` is green; specifically
236-
`Integration_Events_Inherit_From_IntegrationEventBase`,
237-
`Integration_Event_Handlers_Use_InboxGuard`,
238-
`Integration_Event_TopicNames_FollowConvention`.
247+
- `LearnStack.Tests.Architecture` is green — `Integration_Event_TopicNames_FollowConvention`
248+
runs today; `Integration_Events_Inherit_From_IntegrationEventBase` and
249+
`Integration_Event_Handlers_Use_InboxGuard` are registered for
250+
[Phase 02b](../../../docs/roadmap/phase-02b-events-auth.md) and run from there. Check
251+
[the catalogue](../../../docs/standards/21-architecture-tests-catalogue.md) for their
252+
status rather than assuming a net is under you.
239253
- An integration test confirms the round-trip: handler enqueues → outbox row
240254
created → outbox processor dispatches → consumer handles + writes business
241255
state + inbox row.

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ architecture test) plus any other invariant the change touches. See
6969
> what almost every test wants: **every** migration chain applied — through
7070
> `MigrationChains.ApplyAllAsync`, never a list a fixture keeps itself — and every
7171
> table seeded for two tenants, with a second organization under tenant A. There
72-
> are three chains and fourteen tables as of Packet 8; a fixture that names them is
72+
> are four chains and seventeen tables as of Packet 9; a fixture that names them is
7373
> a fixture that goes stale, which is the defect below. Share it with `[Collection(SharedSchema.Name)]` rather than
7474
> `IClassFixture<>`, so one container serves the whole schema suite.
7575
>
@@ -173,11 +173,11 @@ public async Task Unsetting_tenant_context_returns_zero_rows_through_RLS()
173173
}
174174
```
175175

176-
Nothing throws `TenantContextMissingException` today — the type itself shipped in
177-
Packet 3, in `LearnStack.SharedKernel/Errors/`. The `DbCommandInterceptor` that
178-
throws it is described in Standards 05 and 11 and lands in **Packet 7**, which owns
179-
it. Until it does, the fail-closed behaviour is the empty result, which is what to
180-
assert. From Packet 7 the same read **through a module `DbContext`** is a loud
176+
A raw connection with no announcement fails closed as the empty result, which is what
177+
to assert. `TenantContextMissingException` — the type shipped in Packet 3, in
178+
`LearnStack.SharedKernel/Errors/` — is thrown from Packet 7 on by the
179+
`TenantContextGuardInterceptor` Standards 05 and 11 describe, and since Packet 9 by
180+
`FeatureFlags` when asked with no tenant. The same read **through a module `DbContext`** is a loud
181181
`TenantContextMissingException` — the interceptor is an EF `DbCommandInterceptor`
182182
keyed on the marker a sanctioned setter stamps, so it never sees a raw
183183
`NpgsqlCommand`. The case above opens its own connection from `PostgresFixture` and
@@ -212,7 +212,8 @@ and neither write does. Without that case both `AS RESTRICTIVE` guards can be
212212
deleted with the suite green — measured, in Packet 6. Set the variable in the test
213213
itself; nothing sets it at runtime, because the flag derives from the actor's role
214214
and roles arrive in
215-
[Phase 02b](../../../docs/roadmap/phase-02b-events-auth.md). See
215+
[Phase 03](../../../docs/roadmap/phase-03-identity-admin.md), after Phase 02b's
216+
authenticated principal. See
216217
`TenancySchemaTests.TheTenantScopeHatchWidensReadsAndNeitherWrite`.
217218

218219
The Packet 7 half of these cases goes through the **request**, and it needs no
@@ -339,7 +340,7 @@ cases assert on stay what they were.
339340
## Validation
340341

341342
- `dotnet test backend/tests/LearnStack.Tests.Integration` passes the new test.
342-
- The mandatory isolation pair (`TenantA_cannot_read_TenantB`,
343+
- The mandatory isolation pair (`Tenant_A_cannot_read_Tenant_B_data`,
343344
`Org_X_cannot_read_Org_Y_within_TenantA`) is present for every new
344345
tenant-owned / org-scoped entity.
345346
- A negative test confirms RLS-effective behaviour (empty result when

0 commit comments

Comments
 (0)