Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
80f634a
docs(roadmap): close Packet 5 and point at Packet 6
cemililik Aug 27, 2026
a5dfab5
docs(decisions): decide the concurrency token and the unit of work
cemililik Aug 27, 2026
21276b2
docs(standards): make the documents Packet 6 transcribes executable
cemililik Aug 27, 2026
4de25a7
fix(docs): correct what the Opus round measured wrong in Step 1
cemililik Aug 27, 2026
4fb3baa
fix(docs): close what the Sonnet round found in the Opus round's fixes
cemililik Aug 27, 2026
f19728d
feat(kernel): the concurrency token and the tenancy identifiers
cemililik Aug 27, 2026
6ca7534
fix(kernel): close the Step 2 Opus round
cemililik Aug 27, 2026
59828e5
fix(kernel): close the Step 2 Sonnet round
cemililik Aug 27, 2026
1f0bf1f
feat(infra): the four database roles and the migration credential
cemililik Aug 28, 2026
10f3df3
fix(infra): close the Step 3 Opus round
cemililik Aug 28, 2026
e14f577
fix(infra): close the Step 3 Sonnet round
cemililik Aug 28, 2026
6528d49
feat(tenancy): the tenancy schema, its policies and its grants
cemililik Aug 28, 2026
6cc3b9b
docs(modules): the Tenancy module spec
cemililik Aug 28, 2026
135ffb4
feat(platform): outbox_messages and idempotency_keys
cemililik Aug 28, 2026
d0db17b
fix(tenancy): close the Step 4 Opus round
cemililik Aug 28, 2026
f67cacf
fix(tenancy): close the Step 4 Sonnet and Step 5 Opus rounds
cemililik Aug 28, 2026
3b6b9cb
fix(infra): close the Step 5 Sonnet round
cemililik Aug 28, 2026
0a24f26
feat(kernel): the ambient unit of work
cemililik Aug 28, 2026
af5fcac
fix(kernel): close the Step 6 Opus round
cemililik Aug 28, 2026
695892a
fix(kernel): close the Step 6 Sonnet round
cemililik Aug 28, 2026
58bbafa
docs(roadmap): close Packet 6 with the record of what it got wrong
cemililik Aug 28, 2026
bc5ec44
fix(meta): close the pre-PR audit
cemililik Aug 28, 2026
1ed8d83
fix(review): close the PR review round
cemililik Aug 28, 2026
7081294
fix(review): close the second review round
cemililik Aug 28, 2026
4726267
fix(kernel): close the escalation, the leak and the unswept policies
cemililik Aug 28, 2026
6504540
docs(standards): correct what the corpus says about what it ships
cemililik Aug 28, 2026
8155ba4
fix(kernel): redact a quoted value whole, and stamp before mutating
cemililik Aug 29, 2026
5687621
docs(decisions): rewrite ADR-0041 against what git actually shows
cemililik Aug 29, 2026
e27355c
docs(decisions): bound ADR-0041 by canonicity, not by who might copy it
cemililik Aug 29, 2026
6695e51
fix(ci): let the link audit read a fenced block as sample text
cemililik Aug 29, 2026
e0b3137
docs(decisions): make ADR-0041's own disclosure clause local to each ADR
cemililik Aug 29, 2026
4cb3f8b
docs(decisions): accept ADR-0041 and correct the corpus to match it
cemililik Aug 29, 2026
8b82763
fix(kernel): scope the commit flag and canonicalize the locale
cemililik Aug 29, 2026
71e74bb
fix(decisions): let a fingerprint mismatch survive the reclaim
cemililik Aug 29, 2026
93027ae
Merge remote-tracking branch 'origin/main' into development
cemililik Aug 29, 2026
d3b43e7
docs(ci): record the required-check name the live rule still names
cemililik Aug 29, 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
13 changes: 11 additions & 2 deletions .claude/skills/add-architecture-test/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
74 changes: 54 additions & 20 deletions .claude/skills/add-backend-module/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` **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.<Name>.Application/<Name>Module.cs`:

```csharp
public sealed class <Name>Module : ILearnStackModule
public sealed class <Name>Module : IModule
{
public void Register(IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<<Name>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(<Name>Module).Assembly);
services.AddValidatorsFromAssembly(typeof(<Name>Module).Assembly);

Expand All @@ -131,6 +137,25 @@ Call this from the composition root (`LearnStack.Api/Program.cs`):
modules.Add(new <Name>Module());
```

**The `DbContext` registration is a composition-root concern, not a module one.**
`AddModuleDbContext<T>` 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<<Name>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.<Name>.Infrastructure/Persistence/<Name>DbContext.cs`:
Expand All @@ -144,9 +169,11 @@ public sealed class <Name>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(<Name>DbContext).Assembly);
}
}
Expand All @@ -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/<name>/audit.md`.
- The module's permission matrix file exists at
`docs/modules/<name>/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/<name>/`:

- `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
Expand All @@ -184,8 +210,16 @@ create the spec files under `docs/modules/<name>/`:

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_<Name>_Schema \
# INTENT ONLY, snake_case: EF prepends the UTC timestamp, producing the
# <UTC_yyyyMMddHHmmss>_<intent> filename Standards 05 specifies.
dotnet ef migrations add create_<name>_schema \
--project backend/src/Modules/<Name>/LearnStack.Modules.<Name>.Infrastructure \
--startup-project backend/src/LearnStack.Api \
--output-dir Persistence/Migrations
Expand Down
60 changes: 52 additions & 8 deletions .claude/skills/add-ef-migration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,19 @@ and survive forward-only deploy rules
### Step 1: Generate the migration

```bash
dotnet ef migrations add <UTC_yyyyMMddHHmmss>_<intent> \
# 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 <UTC_yyyyMMddHHmmss>_<intent>.cs — the format Standards 05
# specifies. Typing the timestamp as well produces it twice.
dotnet ef migrations add <intent> \
--project backend/src/Modules/<Module>/LearnStack.Modules.<Module>.Infrastructure \
--startup-project backend/src/LearnStack.Api \
--output-dir Persistence/Migrations
Expand All @@ -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
Expand All @@ -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<TId>
-- 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_<name_plural>_tenant_id_id UNIQUE (tenant_id, id)
Expand Down Expand Up @@ -283,12 +302,34 @@ dotnet ef migrations script \
--project backend/src/Modules/<Module>/LearnStack.Modules.<Module>.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/<Module>/LearnStack.Modules.<Module>.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.
Expand All @@ -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;
Expand Down
Loading
Loading