Skip to content

Phase 02a Packet 9 — audit infrastructure and the entitlement socket - #18

Merged
cemililik merged 75 commits into
mainfrom
development
Sep 11, 2026
Merged

cemililik merged 75 commits into
mainfrom
development

Conversation

@cemililik

@cemililik cemililik commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Phase 02a Packet 9 — audit infrastructure and the entitlement socket

Two ADRs decided this packet before it started, because the corpus that governs it was
written before the code it governs: ADR-0044
and ADR-0045.

1975 tests green — 1 contract, 118 architecture, 1373 unit, 483 integration — after
five external review rounds and the follow-ups closed with them (1867 at 025b493).
Counted under CI=true, which makes warnings errors, on a 10.0 SDK backend/global.json
admits; CI installs exactly the pinned one.

What ships

The audit write path, end to end. audit_log and audit_config with three
append-only layers; the change-tracker capture; PostgresAuditStore's four writes; the
merged IAuditCatalog; and the two pipeline steps that use them — AuditLogBehavior
classifies and parks intents at step 3 and reconciles in its finally,
TransactionBehavior flushes the MUST-class rows on the owning frame immediately before
COMMIT. With them the observable half of the fail-closed rule that
ADR-0033 Amendment 3 assigns here: the
audit health check, two counters, and a Critical line.

Two writers outside the pipeline. PlatformAdminScope.EnterAsync records its own
security-event row in a transaction of its own that commits before the operation's
begins — so work that later fails cannot erase the record of entering — and refuses entry
when it cannot. AuditingTenantAssertionRecorder audits a rejected tenant
assertion per occurrence when a principal is attached, and as a burst when none is,
counted in-process so no cache outage can decide whether a MUST-class security event is
recorded.

The entitlement socket. IEntitlementProvider with NullEntitlementProvider
registered in every deployment mode; IFeatureFlags composing over the port rather
than reading platform_entitlement_cache, which is what makes swapping the provider
change the answer without touching module code; the three typed key registries; and
platform_killswitches with its policies, its overlay and its cache family — and no
writer
, because every toggle runs inside EnterPlatformAdminScope, whose registered
gate refuses everyone until Phase 03 brings the Platform-scope permission.

Four decisions, recorded rather than inferred

Three are readings of an Accepted ADR rather than applications of it, so the
delivery record
carries them with their reasoning. The sharpest: ADR-0045 § 6 states two filters that
cannot both be honoured — carry "the keys the corpus already names", and invent none
because "a registry that lists a capability nothing gates is a list that will be wrong
before anything reads it" — and the set of keys with a shipped consumer measured
empty. And architecture/26 requires the limit floor to be "the Starter-tier
defaults compiled into the binary. Never -1, never 0", while the Hub's actual Starter
row carries 0 for three of nine.

What the review rounds found

Each step was reviewed, and the second round repeatedly found the first round's fix. The
two that mattered most were both mine:

  • The MUST-class row was abandoned when the client hung up. context.RequestAborted
    reached WriteStandaloneAsync, and TaskCanceledException is not a DbException — so
    the store never logged, counted or marked the health check, and the recorder's catch
    missed it too. Because the burst window is consumed before the write, a caller who
    counts their own requests and resets the socket on the crossing one silenced the
    detector for the whole window.
  • KillswitchOverlay could not be resolved outside a transaction. It injected the
    module DbContext, whose factory throws when there is no ambient transaction — so
    resolving IFeatureFlags anywhere outside a unit-of-work frame threw before a single
    flag was read, which is the opposite of what the port promises.

The recurring lesson was narrower than Packet 8's and sharper: a rule that cannot tell
clean from blind.
Three guards passed because nothing in the repository violated
them and their mechanism was never exercised. Each now ships with a companion that proves
it can fail.

The external review rounds (after 025b493)

Five external reviews of this pull request each reproduced defects against real
PostgreSQL through the real pipeline; every finding was checked against the code, and each
still-valid one is fixed here with a case that fails without the fix. The shared lesson:
the suite counted rows and did not check what they meant.

  • Round 1 — two blockers, seven majors, nine minors. Replacing a live revision failed
    and rolled back for both customization aggregates (a handler now designates its subject
    through IAuditSubject); successful rows carried no actor or correlation id; an inner
    refusal absorbed by an outer request was recorded as success; a taxonomy's bands never
    reached its row; the platform-scope entry could be erased by the work it admitted; the
    coverage rules could not see a request whose slug another shared.
  • Round 2 — the coverage rules checked a catalogue no composition root builds (each
    root's own catalogue is now held to the discovered one); IsLoaded was trusted as proof
    of a complete collection, which a filtered Include breaks; aggregate-only modules and
    unreadable matrix rows escaped their guards; the documented frame lifecycle differed from
    the code.
  • Round 3 — a created root lost a member to the tracker and was recorded as owning
    the rest; an unrecognised operation-type spelling skipped the comparison; the forward
    join accepted a row in another module's matrix.
  • Round 4 (all 202 files, at f54fc7b) — two raw projection reads left tenant
    isolation to row security alone (both now name the tenant, with cases that read as a
    role row security does not bind); audit_log.entity_id was varchar(100) while a host
    mapping's key admits 253 (a new migration makes it text); three recipes for later
    phases contradicted their own rules — the GDPR redaction's result row, retention after
    partitioning, and a projection past its grace window — and ten minors across guards,
    telemetry and documentation.
  • Round 5 (at cb2d5a5) — three blockers: audit_config's unique index counted
    soft-deleted rows, so the first change of any override would have failed 23505 for
    good (a new migration makes it partial, and a new rule sweeps every context for the
    shape); no 503 carried Retry-After (both Problem Details paths now send 30 s); and a
    validation refusal was documented as audited although step 1 runs before the audit step
    classifies anything (six carriers corrected, a real-pipeline case added). Among the
    majors: a missing designation answered 503 for a programmer error; a sentinel draft was
    reported as a failed write; a store failure raised two Critical lines; the canonical
    same-transaction rule's second clause had no case (one now forces the failure with a
    trigger — xmin cannot prove it, because EF Core saves under a savepoint); a plan key
    was never read against a conflicting tenant row; and dotnet test exits 0 when its
    filter matches nothing, so both backend jobs now refuse a run that executed no test.

Closed in the same pull request: three high-severity transitive advisories
(Microsoft.OpenApi, System.Security.Cryptography.Xml, SSH.NET) that NuGetAuditMode=direct
never reported — the Microsoft set moves to 10.0.12, Testcontainers to 4.15.0, the audit now
covers the whole graph and fails CI's restore on an advisory of any severity, and
backend/global.json pins SDK 10.0.112, which both CI jobs install;
No_Set_Based_Write_Bypasses_The_Audit_Capture, because ExecuteUpdate / ExecuteDelete
/ ExecuteSql* write rows the capture never sees; and the unresolved-entitlement counter
loses a per-tenant label for a condition that is a platform outage.

Accepted ADRs amended, each in its own file:
ADR-0044 Amendments 6 and 7,
ADR-0033 Amendments 5, 6 and 7,
ADR-0045 Amendments 2, 3 and 4,
ADR-0040 Amendment 7 and
ADR-0021's 2026-09-11 amendment; the three
corrections from round 5 carry inline errata beside the false text. An undisclosed edit to
Accepted ADR-0043 is reverted.

Deliberately absent

A killswitch writer (Phase 03 owns the toggle, its permission and its runbook), any limit
gate (Phase 02c, with the first IUsageReporter a soft limit can report to), and
IEntitlementAdminQuery (Phase 02c). No killswitch or entitlement permission key is
reserved in Permissions yet
; Phase 03's document now
carries the Platform-scope gate, the toggle and reserving those keys, since ADR-0045
Amendment 1 § 4 rests the whole no-writer argument on that permission arriving.

Repository settings outstanding (not files in this repo, see .github/CONTRIBUTING.md):
backend integration (Testcontainers) runs on every pull request but is not a required
check, and the meta check is still required under its pre-rename name.

🤖 Generated with Claude Code

cemililik and others added 30 commits September 7, 2026 22:41
Packet 9's corpus is detailed and internally inconsistent: ADR-0033 predates the
ambient unit of work and the seven commands it will audit, so four of its clauses
do not survive contact with the code. ADR-0044 decides the eleven questions that
leaves open before the first audit file is written; ADR-0045 declares the
entitlement port twenty documents name and none define.

Both were reviewed against the shipped migrations, the transaction code and the
Hub repository's entitlement-v1 schema, and the load-bearing database claims were
measured on PostgreSQL 18.6 rather than reasoned about.

ADR: 0044, 0045
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each amendment says what was incomplete, how it was shown so, and every carrier
it changes. None rewrites a Decision: ADR-0033 still commits MUST-class audit on
the business transaction or writes it standalone, ADR-0020 still selects a
provider at the composition root, ADR-0021 is still feature-based.

ADR-0023 Amendment 9 records something the corpus had two ways: audit_log is the
one high-volume append-only table whose id is minted app-side, because the
Indeterminate pair must carry one identity across two connections and a DEFAULT
cannot give two inserts the same one.

ADR: 0033, 0023, 0028, 0021, 0020
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thirty-one carriers stated the contract ADR-0044 and ADR-0045 replaced. The
edits were made per document and then checked by an adversarial pass that read
each diff against the deciding records; ninety-six findings came back and
seventy-nine were applied.

Three of those findings were against the ADRs rather than the carriers, and each
is now an erratum beside the false text plus a dated amendment:

- audit_config was declared org-scoped. It has no organization_id column
  anywhere in the corpus, so it could not take a template whose predicate ANDs
  an organization term. It is tenant-wide; audit_log is unchanged.
- The redaction sentinel was written as a literal that disagrees with the
  constant named in the same sentence. The corpus names
  SensitiveTokenCatalog.RedactedValue and never the string.
- "Row security never constrains the owner" was false, and FORCE exists to make
  it false. Measured: a NOSUPERUSER owner's UPDATE returns 0 rows with no tenant
  announced and 1 with one. The policy constrains the owner by tenant, not by
  immutability — which is a sharper reason for the trigger than the one given,
  and leaves its necessity intact. The earlier probe had run as a superuser.

ADR: 0044, 0045
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cross-corpus review round read the repaired corpus as a whole rather than
file by file, and against the Hub repository's merged code. Six independent
lenses converged on one flaw: the matrix-to-catalogue join is bidirectional and
keyed by request type, while 23 of the 30 slugs the two shipped matrices carry
have no request type — both matrices were deliberately written ahead of code and
both say so. The rule would have been red on its first run against
classification the corpus asked for. ADR-0044 Amendment 3 gives the join two
directions with two domains, plus an anti-rot clause so the scoping cannot
become a hole.

The same amendment moves OperationType, OperationClass and AuditOutcome into
SharedKernel.Audit — they sat in the Audit module's Domain while SharedKernel
records and every module's catalogue source name them, which is the project
cycle ADR-0023 Amendment 9 already resolved for AuditEntryId — gives AuditIntent
the tenant the in-transaction write had no seam for, and assigns an owner to the
sentinel invariant, which nothing enforced.

ADR-0045 Amendment 1 is four places where this corpus and the Hub's shipped code
disagreed and the Hub had shipped: the limit-key vocabularies share no member,
expires_at is nullable against a NOT NULL column, the generation guard drops a
tenant's first real projection, and platform_killswitches has no reachable
writer while DenyAllPlatformAdminGate is registered.

ADR: 0044, 0045, 0036, 0021
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The carrier editors applied a hundred and fifty findings and refused three,
correctly: each needed a decision an architecture document or a standard is not
allowed to take. Those three are now amendments.

ADR-0044 Amendment 4 makes [PiiSensitive] property-granular, jsonb included —
descending into a tenant-authored document to find personal data is the shape
that appears to work, redacts the fields a reviewer thought of, and misses the
ones a tenant invented in a column whose shape is by construction unknown to us.
It also records that audit_config ships with no writer, so a reader does not
conclude the write path was forgotten, and binds the matrix rule to modules that
have code rather than to directories, five of which hold only an AssemblyMarker.

ADR-0033 Amendment 3 gives the audit health check an owner and demand-gates the
deployment-level stop-serving backstop to Phase 11. Phase 02a has no readiness
surface and no operator to answer the page, so shipping the window now would buy
a self-inflicted outage with no runbook.

ADR: 0044, 0033
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine findings, all of the class many editing passes produce and a design review
misses. The sharpest: 31-audit-subsystem.md § 1 still listed
EnterPlatformAdminScope among WriteStandaloneAsync's callers while its own § 7
and Phasing row described a distinct fourth method — and the document never
named WritePlatformScopeAsync at any point in its sixteen hundred lines. Its
sibling carriers had taken the correction; this one had not.

Two invented names are withdrawn: an architecture-test rule Standards 05 cited
that the catalogue does not carry, and an `audit.config.write` permission key
that exists nowhere. A rule or a key named in prose and nowhere else is the
drift the catalogue and the registry exist to prevent.

Also repaired: ADR-0043's 2026-09-04 erratum was a one-cell row in a two-column
table, from Packet 8. The delimiter is added and no word is changed — the
erratum is an ADR-0041 instrument and its text is not this change's to touch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SharedKernel half of the audit write path, and nothing that runs yet:
IAuditStore's four writes with no update, IAuditStateCapture holding an ordered
list of intents, the drafts and captures they carry, and the in-code catalogue
that replaces the IModule hook no packet ever built.

The value types sit here with the ports rather than in the Audit module, because
AuditIntent and AuditEntryDraft name them and that module already references
SharedKernel — the project cycle ADR-0023 Amendment 9 resolved for AuditEntryId,
resolved the same way for its enums.

TenantId.PlatformSentinel is chosen with the schema that stores it, and both
guards ADR-0044 Amendment 3 assigns it now exist. The one in the unit of work is
the one that matters: it is the only announcement path taking a caller-supplied
id, so without it a provisioning command naming the sentinel would announce it
on app.tenant_id and write every MUST row it declares into a pseudo-tenant no
tenant admin watches. The tenants CHECK cannot reach that — a constraint bounds
a row, not a session variable.

Both guards were mutation-checked: removing the sentinel arm fails a test in
each case, and the positive arm still passes, so neither is a guard that refuses
everything.

ADR: 0044, 0045
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven lenses over the port declarations. The one that mattered most was measured
rather than argued: a throwaway project against the real assemblies showed an
integration-event envelope naming the platform sentinel produces a *resolved*
context, and on a container carrying the shipped audit_log policy and grants a
NOBYPASSRLS role announcing that value read the cross-tenant access log and
forged a row in it.

ADR-0044 Amendment 3 assigned the sentinel two guards, where the value is
minted. That premise was incomplete: EventTenantContext.FromEnvelope is a third
producer of a resolved context and the only one fed from a payload. Amendment 5
adds a guard there and one at the announcement site — the second deliberately
redundant, because it is the single site every announcement passes and it is the
sentence ADR-0044 § 1 actually writes. Both are mutation-checked.

The blocker was self-inflicted: this commit's own "every slug starts with the
source's ModuleName" invariant made platform.admin_scope.enter unregistrable
from the only source that could declare it, and the same file names that slug as
the first thing DeclareOffPath exists for. The invariant is scoped to
request-keyed registrations, which is where it is true.

Amendment 5 also settles what fills entity_type and entity_id, because nothing
did: the registration declares the aggregate, and the composer merges every
capture of that type — the earliest before, the latest after. ProvisionTenantCommand
captures Tenant twice, so picking one arbitrarily records half of what happened.

Also: ip_address is IPAddress against an inet column, not a string that maps to
text; and the capture's value slots are named Json because 42 and "42" are
different values.

ADR: 0044
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The architecture document's § 4 sketch and § 5 body predate the ports and had
drifted in a way that mattered: the classify body branched on a descriptor
count, and a count cannot tell `Off` from `Unclassified` — a registered-and-
silent request has zero descriptors, so the eight test-only types would have
been rejected with audit_unclassified_operation by the very shape meant to
admit them. It now uses TryGet and the WritesNoRow arm the ports declare.

Its GDPR handler called AuditEntryDraft.SecurityEvent with five arguments. No
implementation could satisfy it — the record has no factory, AuditEntryId has no
New(), and Timestamp comes from IClock — so the fence now shows the explicit
construction, which is also what an implementer would have had to discover.

The skill taught a capturesBeforeAfter argument that does not exist and never
will: the interceptor captures every tracked entity unconditionally, so there is
no per-registration opt-in. A skill is executable instruction, and that one
would have produced code that does not compile.

ADR: 0044
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sixteen findings, all of the class three editing passes over one surface
produce. Two were fences that would not compile against the records declared
170 lines above them: the AuditLogBehavior sketch still called Declare and
still named AuditEntryId and Module, and the AuditStateCapture class implemented
five fewer members than its own interface. A later step copies those.

Three skills taught members that do not exist — a capturesBeforeAfter argument,
a NotAudited method, and entry.After / entry.Before property names — and two
still told an agent the ports were absent from backend/src, which they have not
been since the commit before last. A skill is executed literally.

Four counts had gone stale in the same way each time: the guard set went from
two to four in one commit and three documents kept saying two, the Tenancy
matrix's planned rows went from thirteen to fourteen, and the roadmap's
amendment range stopped at four. The ModuleName param doc was the one line the
previous fix round did not propagate its own exemption into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fourth migration chain, and the first whose order matters: audit_config
carries the schema's only cross-chain foreign key, to `tenants`, and the
recipe's glob expands alphabetically with Modules/Audit ahead of
Modules/Tenancy. `make migrate` now names Tenancy first, the integration
fixture applies the chains in the same order, and a named rule replays the
recipe rather than searching it for a literal — coverage is not order, and on
a database that already has the schema the difference is invisible.

audit_log takes the canonical org-scoped template and audit_config the
tenant-wide one, per ADR-0044 Amendment 1. Append-only is three layers and
each stops a different actor: the absent privilege for learnstack_app, the
column-restricted UPDATE for learnstack_platform, and two trigger functions
that are the only layer binding the table owner. Each is measured against a
real container rather than asserted in a comment, including the correction
that FORCE does constrain a NOSUPERUSER owner — by tenant, not by
immutability.

ADR: 0044, 0033, 0028
Module: Audit, Tenancy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twelve findings survived adversarial verification; three were mine to
measure rather than argue. The read policy's two OR arms were untested in
the narrowing direction — the suite failed only if the organization branch
was widened, so every mutation that silently hid a caller's own rows stayed
green — and the composite primary key had no test at all: reducing it to
`id` left all 215 integration cases passing while the commit-in-doubt pair
it exists for would have raised 23505 on every occurrence. Three cases now
cover them, each mutation-checked to fail alone.

The rest is the corpus disagreeing with itself. The module spec put
PostgresAuditStore and AuditChangeTrackerInterceptor inside Modules.Audit;
ADR-0044 § 11 puts them in LearnStack.Infrastructure.Audit, and the reason
is a reference edge — the interceptor attaches to every module's DbContext,
so its home must be one every module may reference. Audit Subsystem § 7's
aggregate sketch still named a namespace, a property and a factory the
shipped type does not have, and contradicted a paragraph the same section
gained 214 lines below. And the new ordering rule's catalogue entry claimed
a mutation it does not catch; what it does catch is glob expansion, which
is what the mutation that happened needed.

ADR: 0044
Module: Audit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five findings, and the two that matter were both about a promise nobody
could keep. The module spec's Status line said Packet 9 ships "the capture
and the store, and the pipeline that fills them" — none of those exist yet,
LearnStack.Infrastructure.Audit holds one AssemblyMarker, and both pipeline
behaviors still carry their Packet 3 shells, so the running system writes no
audit_log row at all. A reader would have shipped a feature believing its
writes were audited. The Status line now says which half is delivered and
which is still open inside this packet.

The second: audit_config ships with no writer and six places in the corpus
name Phase 06 as the phase that grants one. Phase 06's own document named
neither the editor nor the migration, and its exit decision makes a screen
owned by no phase an exit blocker — so the phase could have exited clean
with the promise unkept and no later phase claiming it. Phase 06 now names
the editor, its two permissions and the grant that goes with it.

The Outcome converter's comment claimed ignoreCase was there to rescue a
hand-written row in another case. The CHECK admits four lowercase literals
and binds every role, so no such row exists — but the flag is not optional
either: the stored form is lowercase and the member is `Success`, so a
case-sensitive parse throws on every row Phase 03 reads. A new rule now
holds all three closed-set columns to their own CHECKs, mutation-checked
three ways.

ADR: 0033, 0044
Module: Audit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three types ADR-0044 § 11 puts in LearnStack.Infrastructure.Audit, plus
the registration that makes the interceptor reach every module's DbContext
without any module referencing the assembly it lives in: AddModuleDbContext
now resolves ISaveChangesInterceptor from the provider and passes it to
AddInterceptors explicitly, because a DI registration alone does not attach
one under this repository's options shape — measured on EF Core 10, and a
registration that looked wired and was not would produce audit rows with
empty snapshots and no error anywhere.

AuditEntryDraft's twenty-two positional parameters are gone, which was the
debt Step 3 recorded. Ten of its fields are string? and eight sit in one
run, so a positional constructor accepted any permutation of them without a
diagnostic — and the mistake lands in the one table whose rows nothing can
correct. Every member is now `required` and named at the call site, which
makes the transposition a compile error rather than a permanent row.

AuditJson lives in SharedKernel rather than beside the interceptor: the
elision record's shape is a contract on three columns that CapturedEntityChange
already documents, and a later reader should recognise it from the same type
the writer used. Both binding cases either side of the 256 KiB cap are
measured, including that the cap counts bytes of UTF-8 and not characters —
which only a stored document can show, because the serializer escapes
everything else to ASCII.

ADR: 0044, 0033
Module: Audit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twelve findings. Two were real bugs and both would have written a
permanently wrong row.

The merge skipped nulls. ADR-0044 Amendment 5 § 2 says before_state is the
earliest capture's and after_state the latest; filtering the nulls out
walks past the only two captures that carry meaning — the interceptor sets
BeforeJson to null to say the entity did not exist and AfterJson to null to
say it no longer does. ProvisionTenantCommand captures Tenant twice, so
every tenancy.tenant.create row would have carried a complete prior state
and a reviewer diffing it would have read a creation as an update. The
mirror made a delete row assert the entity still existed. My own test
agreed with the code: both its captures had non-null states, so it passed
identically against either expression.

The JSON passthrough decided from the value. A string whose content parsed
was emitted verbatim, so a varchar display name of "[1,2,3]" became a JSON
array — contradicting the file's own rule that 42 and "42" differ — and a
display name carrying a NUL escape or an unbounded exponent was emitted
into a jsonb parameter PostgreSQL refuses (22P05, measured), failing the
audit INSERT inside the business transaction. The passthrough now reads the
property's column type from the model, which is the only place the answer
is knowable. Enums render as their name, matching the three closed-set
columns beside them.

The rest is guards nothing killed. Nothing proved the interceptor was ever
attached — both EF hooks and the AddInterceptors line could be deleted with
1662 green — so a case now saves through the real registration and asserts
the buffer filled. The PiiSensitive gate's only probe was also a
SensitiveTokenCatalog token, so the marker branch was dead; the probe is
renamed and a base-class marker is covered, because every audit column in
this repository is declared on AuditableEntity and a lookup that stops at
the entity type finds none of them. TryAddScoped on a multi-registration
service would have let a second interceptor silently displace the audit
one; both roots now use TryAddEnumerable.

ADR: 0044, 0033
Module: Audit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recovered from two review agents that were killed mid-probe by a session
limit: both had built the same spike and neither got to report it.

EF's Property.CurrentValue is the MODEL value. For a converted property the
model and the column are different objects with different shapes, and the
capture was serialising the CLR side. Measured against the real
Customization model: a LocalizedText display name stores
{"en":"Vocabulary Card","tr":"Kelime Kartı"} and the audit snapshot
recorded {"Locales":["en","tr"]} — which languages exist, and none of the
words. Every display name in the schema is one of these; they are the
tenant-authored values that module exists for; and audit_log is append-only,
so nothing could have recovered the text afterwards.

Every value now goes through the property's ValueConverter first, which
also unwraps a Vogen identifier to its Guid and renders an enum mapped by
HasEnumAsText() as the member name the column stores — so an audit row and
the table it describes cannot disagree.

Two cases cover it: a stand-in with exactly HasLocalizedText's shape, and
one over the real CustomizationDbContext so a change to that mapping is
caught here rather than by a reader noticing the words are gone.

ADR: 0044
Module: Audit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twelve findings, and the second round did what it has done before: it found
the first round's fix incomplete.

BLOCKER. 103803d claimed to have fixed the NUL that fails the audit INSERT,
and had only fixed the passthrough half. An ordinary string property still
reached the row through JsonSerializer, which renders a NUL as a six-
character escape — storable text, and exactly what the jsonb input function
refuses while parsing. So the check has to read the VALUE, not the text it
serialises to. A value carrying one is a value no column holds, since the
business write carrying it fails too; what must not happen is the audit
write failing with it, because the audit write is what records that the
business write failed. It becomes an explicit marker, on the size cap's
precedent.

The concurrency-token exclusion was dead on every shipped aggregate: the
set named RowVersion, which is the COLUMN, while the filter matches the EF
property — and AuditableEntity declares Version. Every diff carried the one
value that moves on every write.

The standalone writer is a FIFTH tenant-announcement site and carried none
of the guards the other four do. Measured: a draft naming
TenantId.PlatformSentinel wrote a platform-scope row through learnstack_app,
into the one table that deliberately has no foreign key to tenants and so
had no backstop either — which is precisely what IAuditStore's own contract
says cannot happen.

Two ADRs say the duplicate-key outcome is "logged, counted, and swallowed"
and the catch said so too while counting nothing. It counts now.

And three guards nothing killed: the synchronous SavingChanges hook could be
emptied with the suite green, the TryAddEnumerable registration was only
ever asserted against a copy of the composition root's lines rather than the
root itself, and KeyOf skipped the converter every other field goes through
— harmless for every key shipped today, which is why it needed a key whose
stored text differs from ToString() to become killable at all.

Every repaired guard is mutation-checked: eight mutations, eight failures.

ADR: 0044, 0033
Module: Audit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reading ADR-0033 against the code that now exists surfaced three points
where the corpus said two things, or said one thing it had no mechanism
for. Amendment 4 settles all three; none changes the Decision.

An override narrows and never elevates. The glossary, the skill and
Audit Subsystem § 5 all described a SHOULD-to-MUST elevation that the
shipped schema cannot express — audit_config carries one boolean and no
tier column. It is refused rather than deferred, and not because of the
schema: a lever that moved an operation onto the durable path would put a
tenant admin in reach of the 503 an in-transaction audit failure produces,
on operations the platform deliberately classified MAY.

A cancelled COMMIT is Indeterminate. AuditLogBehavior's catch excludes
OperationCanceledException because ADR-0032 requires that type to survive,
so a client disconnecting mid-COMMIT skipped the reconcile and the finally
that clears the capture — an ordinary client action dropping a MUST row for
an operation that may well have committed. TransactionBehavior now marks
the capture before rethrowing; the exception leaves Handle unchanged.

And this ADR described the override read twice, as two different
mechanisms. The Implementation Notes clause names an integration event that
does not exist and an out-of-band refresher no phase owns, so it was false
when written; § Decision governs. "Never a request-path query" survives as
what it was reaching for: a query per request is what it forbids, and a
loader that runs once per tenant and generation is not one.

ADR: 0033
Module: Audit, Tenancy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The catalogue, the classifier, and the two behaviors that were shells. A
request is now classified at step 3, one intent is parked per audited
(resource, operation), the MUST rows are written on the business
transaction immediately before COMMIT, and everything the transaction did
not carry is reconciled afterwards.

The reconcile lives in AuditLogBehavior's finally rather than after the
handler, and that placement is the point: a refusal returns, an exception
throws, and a cancelled COMMIT does neither in a way the catch can see. One
place covers all three, and what separates them is the state the owning
unit-of-work frame recorded — a fact about the database rather than about
the control flow.

Two things the suite caught that the code had wrong. A provisioning command
runs under an unresolved context by construction, so reading the context
gave the all-zero tenant and the policy refused the row it was written
under — 42501, on the one command ADR-0042 sanctions; the behavior now
reads IProvisionsTenant.ProvisioningTenantId, which is the value the
transaction actually announces. And resolving the store eagerly forced the
application data source on every request, which turned "no credential" into
a 500 on the platform-host surface that never touches the database; it
takes the same Lazy the platform data source already does.

Every test-only request type is registered Off rather than exempted. An
exemption would have to live in the behavior, where it could not tell a
suite type from a shipped one nobody classified — and the rule exists to
catch the second.

The end-to-end case is the one that matters: a real command through a real
composition root, and the eight rows it leaves in audit_log read back as
learnstack_app wrote them. Everything else in the suite proves a part, and
until this one existed every part could have been right while the thing
they exist for never happened.

ADR: 0033, 0044
Module: Audit, Tenancy, Customization

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven findings. The blocker is the one the light-up commit claimed to have
fixed and had not.

The reconcile handed the request's own cancellation token to the standalone
write. Every path that reconcile was moved into the finally to cover hands
it a token that is ALREADY cancelled by construction, so the write threw at
OpenConnectionAsync before a statement was issued and the catch logged and
dropped the row. Measured against the real database: one row with a live
token, zero with a cancelled one — and the cancelled case is the only one
the code path exists for. So ADR-0033 Amendment 4's mechanism shipped and
its outcome did not, and a client disconnecting mid-COMMIT still lost a
MUST-class row for an operation that may well have committed. The comment
on that catch stated the rule correctly while the code did the opposite.
My own test handed Handle a live token, so it asserted the reconcile's
shape and stayed green.

A rolled-back COMMIT was recorded as Indeterminate. CompleteAsync refuses a
unit an inner frame marked rollback-only, and it issues a real ROLLBACK
before it throws — so the outcome is known with certainty. The exception
type cannot tell that from a genuine fault, and only the unit knows, so
IUnitOfWork gains the read half of MarkRollbackOnly. It decides whether a
permanent row says `failed` or `indeterminate`, and `indeterminate` tells a
reader the COMMIT may have landed.

The slug shape check accepted a trailing newline: .NET's `$` matches before
one, so a slug that reads correctly in every log line joined against
nothing. Anchored with \z.

And three things nothing constrained. The builder's five refusals had no
test at all — the unit suite used a catalogue double and the integration
suite built the real one only through the happy path, so any of them could
have been deleted with the whole suite green. The override branch had none
either, while the module spec said it was exercised by a test that seeds
the row as the migration role; it is now, against the row SchemaFixture
already seeds. And platform.admin_scope.enter was registered PlatformAdmin
where the matrix, ADR-0044 § 10 and the packet scope all say security-event
— caught now by the catalogue-to-matrix direction of
Every_TenantOwned_Command_HasAuditCoverage, which this commit implements.

ADR: 0033, 0044
Module: Audit, Tenancy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sixteen findings from the review round over step 5. Twelve were real and are
fixed here; one was refuted by measurement and is recorded rather than
"fixed"; three were documentation drift.

The largest is not a defect the round found in what shipped but what it found
missing: ADR-0033 Amendment 3 assigns Packet 9 the observable half of the
fail-closed rule -- an `audit` health check, a standalone-write-failure
counter, and a Critical log line -- and none of the three existed. All three
ship now, and the duplicate counter is renamed to the Observability Standards
shape it never had. `PlatformAdminScope.EnterAsync` likewise still only logged
a Warning while the catalogue declared its slug MUST / SecurityEvent; it now
writes the row on its own transaction before the operation runs, and refuses
entry when it cannot.

The rest are tests that could not fail. Both fixture tenants overrode the same
`audit_config` slug, so the cross-tenant isolation case answered correctly for
a fully leaking read; two `IUnitOfWork` doubles carried a comment saying
`MarkRollbackOnly` set `IsRollbackOnly` while neither did; the owner-only guard
on `WritePendingAsync` and the `CancellationToken.None` on the best-effort
write were both unconstrained -- removing either left the whole suite green.

The refuted one is the refused-vs-faulted branch in `TransactionBehavior`.
Measured: `NpgsqlUnitOfWork.CommitFrameAsync` refuses a rollback-only unit with
a real ROLLBACK before it throws, exactly as the double models, and inverting
either half of the branch turns a test red. What was true is that the case
configured a `CommitFailure` the double never reaches; that setup is gone.

Every guard added or repaired here was mutation-checked.

ADR: ADR-0033, ADR-0044
Module: Audit, Tenancy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`AuditingTenantAssertionRecorder` turns the two `tenancy.tenant_assertion.*`
slugs from declared into written. Packet 4 shipped the seam and said plainly
that it recorded to a log and not to `audit_log`; this is the implementation
that sentence promised.

It DECORATES the logging recorder rather than replacing it. The counter and the
warning cost no I/O and are what a deployment still has when the audit store is
unreachable; the row is the durable record. Keeping them apart also keeps the
architecture rule that only one file may name those two counters true of a file
that does nothing else.

Two tiers, because their amplification profiles differ. A mismatch carrying a
validated principal is bounded by token issuance and the actor is the finding,
so every occurrence is a row. An anonymous one is bounded by nothing, so the
BURST is audited -- once per (resolved tenant, dimension, window), counted by an
in-process detector that no cache outage can switch off. The row always carries
the RESOLVED tenant: the standalone write announces `app.tenant_id` from the
draft, so the asserted id would hand an anonymous caller a primitive that writes
into a tenant of its choosing. The asserted value is metadata, typed as a Guid.

The authenticated tier is dormant -- there is no `UseAuthentication` until Phase
02b, so `IsAuthenticated` is constant-false. It ships anyway: which tenant the
row carries is a one-way door, and Phase 02b's first authenticated traffic is
the worst moment to decide it.

A failed write never changes the response. A refusal has no unaudited state
change to protect, and a 503 an anonymous caller can provoke is an availability
signal that caller controls.

With it, `Assertion_Recorder_Is_The_Only_Mismatch_Writer` finally gets the
`IAuditStore` half it always claimed: a second file naming either slug now fails.

ADR: ADR-0036, ADR-0033
Module: Tenancy, Audit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six reviewers over the recorder, the detector, the DI graph, the corpus, the
attack surface, and the test suite's own ability to fail. Four of them found the
same Blocker-adjacent defect independently, two by probing Npgsql directly.

THE MAJOR: I threaded `context.RequestAborted` into the MUST-class write, so the
client held the cancellation token for the row recording that client's own
misbehaviour. `TaskCanceledException` is not a `DbException`, so the store never
logged Critical, never counted, never took the health check unhealthy -- and not
an `AuditWriteFailedException` either, so the recorder's catch missed it and the
404 became a 500. The burst window is consumed before the write, so a caller who
counts their own requests and RSTs the crossing one silences the detector for
the whole window. Where the design intends ~24 rows/tenant/hour they get zero.
`AuditLogBehavior` documents this exact trap one file over, with a measurement.
The seam now takes no token at all: the request that would cancel the write is
the request being recorded.

The catch was also too narrow, which a probe demonstrated rather than argued:
twelve anonymous mismatches against a host with no application credential
answered 404 x9, 500, 404, 404 -- the 500 landing on the crossing, because the
Lazy data source's failure is not a `DbException` and so is never translated.
That is the availability signal an anonymous caller controls that this design
refuses to produce. It is wide now, and Critical carries the exception.

THE TESTS THAT COULD NOT FAIL. Eight surviving mutations, every one now dead.
Two of them un-shipped the packet with a green suite: reverting the registration
to the logging recorder, and making the burst detector scoped so it counts to
one per request and crosses nothing. A third swapped resolved and asserted in
the middleware -- the write primitive ADR-0036 exists to deny -- and nothing
noticed, because the recorder's test constrains the row given a rejection and
nothing constrained who fills the rejection. The rest: the decorator could stop
forwarding `RecordUnresolved`, the burst row could claim it was authenticated,
the configured window could be ignored entirely, and the timestamp and
correlation id were unread.

`Assertion_Recorder_Is_The_Only_Writer_Of_Its_Audit_Slugs` scanned `LearnStack.Api`
alone, so a module -- where `IAuditStore` is actually reachable from a handler --
passed it, and its exemption named a path outside its own scan root and could
never match. It scans `backend/src` now, and removing the exemption turns it red.

Also: the correlation id the resolver already set (the comment justifying its
absence was false in both clauses); boot-time refusal of a non-positive burst
window, which measured at zero crossings ever with no signal anywhere; and four
documents that described this path as it was rather than as it is.

ADR: ADR-0036, ADR-0033
Module: Tenancy, Audit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four reviewers over the fixes themselves, the end-to-end behaviour, the corpus,
and the test suite's own ability to fail. The mutation pass found five survivors
IN THE PREVIOUS ROUND'S FIXES, which is what a second round is for.

The widened `catch (Exception)` could be narrowed straight back to
`InvalidOperationException` and stay green -- the width IS the fix, and the only
case exercising it threw one type. Its `Critical` line could be deleted outright
or demoted, unconstrained; the commit message claiming "Critical carries the
exception" had no test behind it. `.ValidateOnStart()` could be deleted, because
both boot-guard cases resolved the detector directly and tripped the validator
lazily through `IOptions.Value` -- they proved validation-on-first-use while
calling themselves "at boot". `SourceOffenders`' exemption could be made a
tautology and stay green, because no third file in backend/src names the slugs:
a rule that cannot tell "clean" from "blind", which is the same defect its own
first draft had one layer up. And the metadata key set was open, so the `event`
key removed for duplicating a column went unnoticed coming back.

A real behavioural gap came with them. `PostgresAuditStore` translated only
`DbException`, so every OTHER way a standalone write dies -- a Lazy data source
with no credential, the initializer refusing a role that can bypass RLS, both
thrown from inside `OpenConnectionAsync` -- left the `audit` health check GREEN
while no row could be written at all. That is the precise question the check
exists to answer. All three signals now fire for those too, and the exception is
rethrown unchanged so no caller's contract moves.

The architecture doc's §5 and §6 listings were rewritten in `14d7593` with a
caption saying they were the shipped body. They were not: they named an
`AuditFrame.IsInside` that does not exist, used an undeclared `refusal`, and
renamed three constructor parameters. Corrected, and they now state what they
elide instead of claiming to be the file.

Verified rather than asserted: Npgsql's defaults are Timeout=15s and
CommandTimeout=30s and neither is overridden here, so dropping the request token
left the write bounded after all.

ADR: ADR-0033, ADR-0036
Module: Audit, Tenancy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR-0045 § 5 needs `platform:tenancy:killswitch`, and says widening a closed
guard "is a decision rather than an edit" and that the family is "enumerated,
not opened". So the guard now dispatches on the FAMILY rather than on the shape.
Written as "three or four segments under the sentinel" it would have admitted
`platform:tenancy:settings` — settings are tenant-owned and one entry per
tenant, so that spelling collapses every tenant into one bucket, which is the
single collision this class exists to make impossible.

Three segments, not four, and the four-segment per-key spelling is REFUSED
rather than left unused: one entry holds the whole switch set, so a toggle
invalidates one key — and it has to, because `ICacheService` deliberately has no
`RemoveByPrefixAsync`, so a per-key family would be a set nothing could sweep.

With it, a defect the family would have shipped into: `InMemoryCacheService`
labelled EVERY key under the sentinel `hub:host-map` without reading segments 1
and 2. Correct while the host map was the only platform family, and silently
wrong the moment a second one landed — the overlay's hits, misses and evictions
would have been summed into the host map's on all six instruments, leaving one
dashboard figure neither operator could read.

Module: SharedKernel, Infrastructure
ADR: ADR-0045

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Packet 6 declared `platform_entitlement_cache.valid_until` NOT NULL against a
wire contract that makes `expires_at` required AND nullable. The Hub's DTO
carries `DateTimeOffset?` and it sends null for every trial and perpetual
licence -- the cohort it creates first -- so the only sanctioned writer would
have been structurally unable to persist what its own source sends. ADR-0045
Amendment 1 § 2 assigns the alter here and notes it is cheap precisely because
no row exists yet.

Null means "no scheduled expiry" and is never coerced to a sentinel: a
far-future date in its place would silently become an expiry somebody
eventually has to explain, on a row nobody re-reads until a paying tenant stops
working.

A NEW migration rather than an edit to the applied one. `__EFMigrationsHistory`
holds a migration id and a product version and no checksum, so editing Packet 6's
migration is a silent no-op against an applied database and a different schema
against a fresh one -- undetectable in both directions.

The `Down` keeps the scaffolder's `DateTimeOffset.MinValue` deliberately rather
than choosing a far-future date. Reversing to NOT NULL cannot preserve the
meaning of null, so every such row must become some instant; MinValue reads as
long expired, and granting an unbounded entitlement on the way down a rollback
is the one outcome worse than losing the row's meaning.

Proved by a real INSERT as `learnstack_app` rather than a read of
information_schema: the declared nullability is only half the claim, and the
half that bites is whether the row survives the policy's WITH CHECK.

ADR: ADR-0045
Module: Tenancy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The second platform-scoped table, and the class is a fit for the stated reason
rather than by analogy: the rows belong to no tenant, so there is nothing for a
tenant predicate to isolate. ADR-0045 § 5 is the decision Database Standards
demands for one; the DDL is TRANSCRIBED from that standard rather than
re-derived, because it is the only sanctioned copy and the last time a template
was duplicated it shipped broken into four files at once.

The alternative could not be written at all. A killswitch held in
`tenant_feature_flags` "for the sentinel platform tenant" fails
`fk_tenant_feature_flags_tenant REFERENCES tenants (id)`, since the sentinel has
no `tenants` row by CHECK -- and a foreign key is a constraint, so no role and no
BYPASSRLS attribute moves it.

The read is unconditional and that is the point: the switch is global by
construction, so hiding it from the role that has to honour it would only fail
open. `USING (true)` widens nothing -- there is no tenant term to widen -- and
the GRANT bounds the role instead: learnstack_app holds SELECT and nothing else.

THIS IS THE FIRST TABLE for which "no tenant context implies zero rows" is
deliberately false, so both whole-schema sweeps are taught the difference
WITHOUT a name-based inclusion list -- such a list fails open, which is how a
second permissive policy on `outbox_messages` once passed the whole suite. They
keep enumerating the catalogue and only the expectation changes, and two things
are newly asserted: the POSITIVE half, that learnstack_app reads all of the
table with no context (a sweep that merely excused it would pass against a
policy returning nothing, and a killswitch nobody can read fails open), and the
SET of unconditional policies in `public`, read from pg_policies and pinned to
exactly this one, so a second USING (true) landing anywhere fails.

No writer ships with it, and that is reachability rather than scheduling: every
toggle runs inside EnterPlatformAdminScope(reason) whose registered gate is
DenyAllPlatformAdminGate, so a command shipped now would be unreachable code
keyed on a permission nothing registers. Phase 03 owns the toggle, its
permission and its runbook. The learnstack_platform write grant is written ahead
of its caller, which is also the role the fixture seeds through -- learnstack_app
holds no write, and the owner is denied because the only policy names
learnstack_app, so under FORCE none applies to learnstack_migration.

ADR: ADR-0045
Module: Tenancy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FeatureKey / LimitKey / KillswitchKey as value objects, so a key must be
declared before anything can ask about it, plus the descriptors the resolver
reads and the sixteen / nine / three keys the corpus fences name.

THE SPELLINGS ARE THE HUB'S, verbatim. Measured: the intersection between the
limit keys LearnStack's four documents named and the nine the Hub sends is
EMPTY, and the Hub has merged code, a plan editor and two validators built on
its spelling while this side had a declaration and no implementing line. A key
the Hub never sends misses on every real projection and falls through to its
floor -- a paid tenant silently reading as unentitled, reported as success.

THE FLOORS ARE NOT THE HUB'S STARTER ROW, and that is a decision rather than a
transcription error. architecture/26 requires the fallback to be "the
Starter-tier defaults compiled into the binary. Never -1, never 0" -- and the
Hub's Starter row carries 0 for three of the nine. Both cannot hold. Copying it
ships the exact failure that sentence prevents: a floor of 0 on
limits.api_rate_per_minute denies every API call the moment a projection is
late. The six non-zero values are transcribed; classroom_minutes_per_month,
recording_storage_gb and api_rate_per_minute get LearnStack's own smallest
working allowance (60 / 1 / 60), each with its reason beside it.

Every descriptor carries the two things ADR-0045 Amendment 1 § 5 requires. The
degraded posture is explicit per ADR-0034, taken from architecture/26's
authority table where it classifies and FailClosed where it is silent -- an
unknown answer must not open a surface, and a posture guessed by an implementer
is a security default nobody re-reads. The killswitch correspondence is NAMED,
never derived: inferring `killswitch.classroom.recording` from
`classroom.recording` by prefix would make a renamed key silently ungated, and
the expensive path would stay on through the incident the switch exists for.
Exactly one feature names one, and AnalyticsIngestEnabled deliberately does NOT
gate analytics.advanced_reporting -- ingest is the write side, the feature is a
plan capability over the read side, and a correspondence invented here would
switch off a paid capability during an unrelated incident.

Registries ship before their gates because the SPELLING is the one-way door --
it lands in the Hub's validators, in persisted jsonb and in a pinned wire schema
-- while MEMBERSHIP has a written exit in Feature Flags' deprecation cycle. No
gate ships here; Phase 02c owns the enforcement path.

ADR: ADR-0045, ADR-0034
Module: SharedKernel

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IEntitlementProvider, EntitlementProjection and NullEntitlementProvider,
registered as a singleton at both composition roots in every deployment mode --
not Development only. ADR-0035 names this the working default for the gate, with
Phase 02c as the owning phase and "a tenant must be billed or plan-gated" as the
trigger; until that fires a mode-conditional registration would only make four
of the five modes unbootable for a capability none of them uses.

THE DICTIONARIES ARE POPULATED, and empty ones would be a silent inversion.
Returning empty compiles and reads identically on the feature half -- an absent
feature key resolves to its catalog default, false, and "no plan gating" looks
the same either way. The LIMIT half reverses: an absent limit key resolves to
its catalog FLOOR, which is positive by construction, so a provider promising
"no ceiling" would hand every caller the Starter allowance and nothing would
notice. The registries are the source of both lists, so a key added to one
cannot be missed here.

Generation is 0, below the 1 the Hub's first projection carries. The guard
admits the equal case -- ADR-0045 Amendment 1 § 3 reads § 1's strict `<` as
`>=`, because the equal case IS the provisioning flow: the provisioning insert
writes generation default 1 and the Hub's first real projection also carries 1,
so a strict comparison discards it and reports success while the tenant keeps an
empty row.

RefreshAsync reports IgnoredAsStale and never Applied. This provider persists
nothing, so Applied would claim durability nothing performed -- the same defect
class as a unit-of-work joiner reporting Committed for a row nothing committed.

ExpiresAt and GraceUntil are null rather than a far-future instant, for the same
reason the column became nullable one commit ago: a sentinel date silently
becomes an expiry somebody eventually has to explain.

ADR: ADR-0045, ADR-0035, ADR-0034
Module: SharedKernel

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IKillswitchOverlay in SharedKernel, its Tenancy implementation over
platform_killswitches, and no writer -- Phase 03 owns the toggle, because every
one runs inside EnterPlatformAdminScope whose registered gate refuses everyone.

ONE cache entry holds the whole switch set, not one per key. A toggle must
invalidate a single key, because ICacheService deliberately has no
RemoveByPrefixAsync to sweep a per-key family with -- which is also why the cache
family this reads under is three segments and why its four-segment spelling is
refused by the guard rather than merely unused.

A read failure resolves to the key's DEFAULT and logs at Error. Returning false
there would disable every gated path on every instance until someone noticed: a
killswitch nobody can read is one that fails open, and that is the correct
direction precisely because flipping one is the exceptional act. The default is
read from the registry rather than hard-coded true, so a key the registry does
not declare answers enabled AND says so -- nothing can flip it, so nothing should
be gating on it.

An absent row and `true` mean the same thing: nobody has flipped it.

Tested against the real table as learnstack_app with NO resolved tenant, which
is the half that matters: the role holds SELECT and nothing else, the only
policy is USING (true), and a read that returned nothing would fail open and
look exactly like a deployment with no switch flipped. The flipped case is
written as learnstack_platform -- the role every real toggle will use, and the
only one that can write there at all.

ADR: ADR-0045
Module: Tenancy, SharedKernel

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cemililik and others added 28 commits September 11, 2026 14:49
The coverage rules construct every IAuditCatalogSource by reflection,
which lets them see a source nobody listed and blinds them to the
registrations. The second review deleted the API's
TenancyAuditCatalogSource line: every case stayed green while the
running system refused every Tenancy request as unclassified.

Each root's own IAuditCatalog — the API's and the seeder's — is now
resolved from its container and required to register every shipped
request type and to hold exactly the entries the discovered sources
declare. The discovery the handler-composition case already used moves
to one helper both read. Mutation-checked for both roots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two holes the second review measured in the coverage rules. The module
sweep read request types only, so an aggregate added to a scaffold
module's Domain — no handler, no matrix — passed; the Audit module
itself ships aggregates and no request. And the forward join skipped a
row whose class cell parsed to nothing or whose operation type the enum
does not have, so a MUST row rewritten as "Off" stayed green.

The rule is renamed Every_Module_With_An_Aggregate_Or_A_Request_Has_A_Matrix
and discovers aggregate roots beside requests; its companion feeds it an
aggregate-only module. The forward join reports an unreadable class or
type as a problem, with its own companion.

ADR: 0044
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The behavior opens its frame before classifying and declares inside the
block its finally guards, and frames a silent request too. Nothing held
either: moving the declaration above the try, and returning early for a
WritesNoRow request, each left every unit case green.

Two cases now fail those mutations — a cancelled classification still
reconciles the intent it declared and clears, and a silent request still
owns the frame a nested audited request joins, so the joiner neither
writes a premature row nor clears the owner's intent.

ADR: 0044
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Carries the second round's fixes into the documents that describe them.
Audit Subsystem § 5's listing classified before the block that closes
the frame and returned early for a silent request production still
frames; it now follows the file's order, which two new unit cases pin.
The contained-entity sections, the Customization matrix and the glossary
state the narrowed membership rule — a collection is snapshotted only for
a root created in the request. The matrix rule's new name, the forward
join's unreadable-row failure and the composition-root half of the
request sweep are in the catalogue, whose counts are re-measured: 85
methods, 110 cases. The delivery record gains the round and 1941.

ADR: 0044
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A root created in the request was treated as complete for the rest of
the request. A persisted member detached between two flushes keeps its
row and leaves the tracker, so the next capture recorded the root as
owning what remained — measured by the third review on PostgreSQL:
three bands saved, two detached, the root renamed, and the row's
after_state listed one.

The interceptor now remembers the members each created root was seen
with. Once one of them leaves the tracker, the root's membership is
unknown for the rest of the request and is left out of both snapshots,
as a loaded root's already is; every member's change is still in
`changes`. A deleted member is no loss, because the save that deletes
it is captured while it is still tracked. Unit and workflow cases pin
both, and each fails without the check.

ADR: 0044
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two holes the third review measured in the catalogue-to-matrix join.
The type annotation was matched only in lowercase-and-hyphen form, so
`ReadSensitive` or `bogus_kind` on a registered row read as "names no
type" and skipped the comparison. And the join searched every matrix,
so a row moved into another module's table still passed.

The annotation is now extracted whatever it says, then normalised,
validated as letters only and compared. The row is looked for in the
registering module's matrix alone, and a copy in any other fails;
`platform.*`, whose slug names no module, is the explicit exception —
one row, in the writer's matrix. The reviewer's four reproductions on
the real matrices each fail the rule now.

ADR: 0044
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The contained-entity sections, the Customization matrix and the glossary
qualify the membership rule: a created root's collection is known only
while the tracker holds every member it was seen with. Audit Coverage
states that a registration's row is in its own module's matrix, with
the platform exception, and the catalogue entry names both new failure
modes and the new companion. Audit's one PascalCase type annotation
takes the documentation's spelling. Counts are re-measured — 86
methods, 111 cases, 1945 in all — and the delivery record gains the
round.

ADR: 0044
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A transitive audit found three high-severity advisories no build had
reported: Microsoft.OpenApi 2.0.0 through Microsoft.AspNetCore.OpenApi
(GHSA-v5pm-xwqc-g5wc), System.Security.Cryptography.Xml 9.0.0 through
EF Core Design's MSBuild (eight .NET advisories), and SSH.NET 2023.0.0
through Testcontainers (GHSA-q939-rpr3-3284).

The Microsoft set moves together to its 10.0.12 servicing release —
EF Core 10.0.12 lifts Microsoft.Extensions.* and a partial move is a
downgrade error. Every project on the Npgsql provider references EF
Relational beside it, because the provider takes Relational at its
floor and the module would otherwise compile against an older relational
layer than LearnStack.Infrastructure (MSB3277). Testcontainers moves to
4.15.0, the first line that pins a patched SSH.NET, and the fixture
takes its image through the constructor 4.x requires. The full suite is
unchanged, and a transitive audit reports nothing in any of 45 projects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NuGetAuditMode was `direct`, which is why the three advisories fixed
in the previous commit went unreported: every one was transitive. The
audit now covers the whole graph. Restore is where NuGet reports, and a
--no-restore build only replays the finding as a warning — measured —
so both restore steps run with CI=true, which the runner already sets
and which the gate now states. Reverting one bump fails the restore
with NU1903.

CI's SDK moves from 10.0.100 to 10.0.112 in the same feature band: its
runtime, 10.0.12, carries the .NET advisories 10.0.0 did not. The CI
sequence — restore, format verify, Release build, both test filters —
passes on that SDK locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The capture records what the change tracker holds and nothing else
(ADR-0044 § 7). EF Core's ExecuteUpdate, ExecuteDelete and ExecuteSql*
write rows no entry describes, so an operation written that way would
commit an audit row with no before, after or changes, and nothing would
fail. Nothing uses them today; they read as ordinary EF, which is what
makes them the likely accident.

No_Set_Based_Write_Bypasses_The_Audit_Capture scans backend source for
them, comments excluded, and its companion proves the scan finds a call
split across lines and ignores prose. A call planted in a module fails
the rule.

ADR: 0044
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The set-based write rule is catalogued and carried where a writer
looks: Database Standards § Raw SQL now also says where a hand-written
write may still go, beside Audit Coverage, the Audit Subsystem, the
glossary, the audit skill and CLAUDE.md. The unresolved-entitlement
counter loses its per-tenant label — the condition it counts is a
platform outage, and the tenant belongs in the log line and the span —
and both entitlement counters enter the Observability inventory with
Phase 02c as their owner. Counts are re-measured (88 methods, 113
cases, 1947 in all) and the delivery record gains the follow-ups,
including the dependency advisories.

ADR: 0044
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each Accepted ADR the review rounds changed carries a dated amendment
in its own file, as ADR-0041 requires, and § Decision is unchanged in
all four.

ADR-0044 Amendment 6 records what a row is about when a request writes
more than one thing: a handler designates the subject when it writes two
instances of its aggregate, the actor and correlation id travel on the
intent, each intent keeps its own request's result, a contained entity
is captured with its aggregate — its membership only where it is known
to be complete — and `changes` pointers are instance-qualified. ADR-0033
Amendment 5 corrects the standalone writer's callers and defers to
ADR-0044 for a nested request's outcome. ADR-0021's 2026-09-11 amendment
and ADR-0045 Amendment 2 read registry membership as the vocabulary the
contract names, with enforcement waiting for a consumer. The index rows
list every amendment.

ADR: 0021, 0033, 0044, 0045
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tenant-flag loader and the audit_config override loader announced
app.tenant_id and then selected every row the database returned, so
row security was the only isolation layer. Database Standards § Raw
SQL requires the tenant predicate as well. With row security out of
the way — measured by the fourth review, reading as a role it does not
bind — tenant B's flag became tenant A's.

Both queries now carry tenant_id = @tenant, bound from the trusted
argument. Two cases read as learnstack_platform, which bypasses row
security by design, so the predicate is the only layer left to pass
them; dropping it fails both. The override loader's Error line also
stops claiming the audit health check reports the failure, which it
never did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
audit_log.entity_id was varchar(100), a second and narrower bound on
keys their own tables already bound: a host mapping's key is its host,
which platform_host_to_tenant admits to 253 characters. A valid
101-character host failed its MUST row with 22001, rolled the mapping
back, and failed the standalone record of the attempt the same way.

A new migration makes the column text — binary-coercible, so no rows
are rewritten — rather than editing the one that created the table,
which a development database may already hold. Truncation was never an
option: a shortened key names a different subject. A pipeline case maps
hosts of 100, 101 and 253 characters and reads the row back; without
the migration 101 and 253 fail. dotnet-ef moves to 10.0.12 with the EF
packages it scaffolds against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The store logs Critical, counts and marks the audit health check for
every failure WriteStandaloneAsync throws, translated or not. The
tenant-assertion recorder then logged a second Critical for the same
failure, saying the store had not counted it — measured by the fourth
review composing the two for real: counter 1, health unhealthy, two
Critical lines.

IAuditStore now states that reporting contract, and the recorder's
wide catch keeps the refusal unchanged and adds only a Warning that
the refusal stands. A case composes the real store with a data source
that cannot be built and pins one counted failure and one Critical; the
port's best-effort remark is corrected to say the store, not the
caller, logs and drops a failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two guard blind spots the fourth review measured. The append-only sweep
matched only a bare, unquoted audit_log, so UPDATE public.audit_log,
UPDATE "audit_log" and DELETE FROM "public"."audit_log" passed; it now
reads a schema qualifier, identifier quotes and ONLY, and its exemption
list is an exact, empty set of paths instead of a substring that
exempted any Audit-module file named for redaction.

And the forward join compared only the first row carrying a slug, so a
correct row followed by a contradictory copy passed while the reverse
order failed. Every carrier is compared now, and the reverse direction
refuses a slug classified in two rows. Each companion fails against the
old code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three recipes for phases not yet built contradicted the rules they
serve, and are corrected before anyone follows them. The GDPR redaction
writes its MUST result row on the platform transaction that redacts,
before it commits; the port change that allows it is recorded as Phase
03's decision. The retention purge stays a row delete after
partitioning, since one monthly partition holds many tenants and
classes. And a projection past its grace window resolves read-only, as
ADR-0021 decides, in the architecture and Phase 02c's criteria alike.

The rest say what the code and decisions do: the audit health check
answers only whether a MUST row can be written; the setter inventory
counts the tenant-flag loader as the eighth; the Hub keeps its own audit
stream; an org admin's audit view is a scoped binding, not a query
filter; three algorithm listings match their files; the PublicSurface
cross-check is written; UI gating reads IFeatureFlags. Counts are
re-measured — 89 methods, 114 cases, 1954 in all.

ADR: 0021, 0028, 0033, 0040, 0044, 0045
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each Accepted ADR the round changes carries a dated amendment in its
own file, as ADR-0041 requires, and § Decision is unchanged in all
three.

ADR-0033 Amendment 6 says what the audit health check answers — only
whether a MUST-class row can be written — so a tenant-override read
failure is its Error line alone, reconciling § Decision's "surfaced on
the audit health check" with Amendment 3's definition. ADR-0040
Amendment 7 adds the tenant-flag projection loader as the eighth
app.tenant_id setter. ADR-0045 Amendment 3 narrows Amendment 1 § 5: a
key's fail-open/fail-closed class decides only when no projection
exists, and one past its grace window is read-only, as ADR-0021
decides. The index rows list the new amendments.

ADR: 0033, 0040, 0045
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The unique index on (tenant_id, module_name, operation) counted soft-deleted
rows. An override has no setter, so it changes only by a soft delete and a
fresh declaration, and the first change of any override would have failed
23505 for good. Every other soft-deletable table already filtered on
deleted_at IS NULL. A new migration rather than an edit to the applied one;
nothing writes audit_config until Phase 06.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
API Design requires the header on a 503, Error Handling said Packet 9 had
set it, and no 503 carried it. It is set where the status is decided - on
both Problem Details paths, the exception handler and the action result -
rather than where each code is minted, so audit_unavailable,
dependency_unavailable and the idempotency store's capacity refusal all
carry it, and a later 503 code inherits it. Thirty seconds is the provider
circuit breaker's default break duration. The audit_unavailable arm of the
status map is pinned beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fifth review found the write path's contracts disagreeing with what it
did. A missing designation answered 503 audit_unavailable for a programmer
error; the composer now throws InvalidOperationException, and two keyless
captures are two instances. A draft carrying the platform sentinel was
reported by the store as a failed write - Critical, counter, health - when
it is the caller's error; both the standalone and the best-effort paths
refuse it with ArgumentException before writing, and the caller that
composed it owns the alert. The reconcile raised a second Critical for a
failure the store had already reported and now logs Warning there; the
assertion recorder alerts on a refused draft itself.

The catalogue refuses one slug registered both request-keyed and off-path,
one (request, slug) pair twice, and an entity type no capture can carry.
AuditJson writes letters as letters: the default encoder escaped every
non-ASCII character, so a Turkish name reached the size cap three times
sooner than its bytes did. The anonymous burst's window staying consumed
after a failed write, and the best-effort path's log line, are asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IFeatureFlags trusted IsResolved and read whatever id the context held. An
id never constructed failed from inside the cache key with an exception
about the id type, and the platform sentinel read as a tenant - a value the
hard rules forbid a request to carry, and a real lookup key once a
persisting provider lands. Both are refused as a missing tenant context, on
the feature and the limit paths.

The plan-feature case now reads against a conflicting tenant row in both
directions, and the limit case against one too: without the rows, a
resolver that fell back to the tenant table for a plan key passed. The
entitlement default is asserted in SaaS as well as Development. And the
port's IgnoredAsStale summary no longer describes the equal case the guard
applies; the section references name the table that exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tenancy edge and the persistence root both registered a
Lazy<NpgsqlDataSource>, so which one the container kept was a matter of
call order. It is registered once, beside the data source it defers, and
the edge takes that one. The composition roots' comments for the audit
health check and the entitlement socket sat above the wrong registrations
after the socket landed between them; each is back beside its own line.

The platform-scoped descriptions now count the killswitch table - two
entity types get no tenant filter, not one - and two cache-key test names
and a factory summary stop calling the host map the only platform family.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The canonical same-transaction rule's second clause was catalogued as
implemented with no case: a trigger now refuses one host's audit insert,
and the mapping that host names does not commit. xmin was tried first and
cannot prove it - EF Core runs SaveChanges under a savepoint inside an open
transaction, so rows on one transaction carry different xmins. A command
refused at validation, step 1, is shown to write no row through the real
pipeline, with a valid send of the same operation as the control.

Unique_Indexes_On_Soft_Deletable_Tables_Exclude_Deleted_Rows sweeps every
module context for the audit_config shape; the Docker-trait sweep reads the
whole integration project; a matrix row's off-path marker must agree with
the registration; and two source sweeps assert their premise before their
verdict. Each new guard has a companion, and each was mutation-checked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The security bump to SDK 10.0.112 landed in the workflow alone, leaving
backend/global.json at 10.0.100 for every local and release build. Both
.NET jobs now install the SDK global.json pins, so the two cannot disagree.
dotnet test exits 0 when a filter matches nothing, so a renamed Docker trait
would have left the whole Testcontainers suite unrun behind a green check;
each backend job now fails a run in which a required assembly executed no
test. The NuGet audit level is stated as low rather than inherited, and the
prose that said "high" says any.

The pre-commit hook's WIP probe carried the pipefail trap its stash pop was
fixed for: head -n 1 killed git ls-files with SIGPIPE once the untracked
listing outgrew the pipe buffer, ending the hook before the stash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A validation refusal is not a standalone write - step 1 runs before the
audit step classifies anything - and six carriers said it was. Retry-After
is now set on every 503, where Error Handling had said it already was.
Reason carries the platform scope's reason and nothing else; a refusal's
cause is error_key. The plain entities the capture predicate was blind to
are counted once, in Audit Coverage, and the other carriers link there.

Owners are written into the phases that hold them: Phase 02b the audit
frame a delivery's transaction must own and the rejected assertion's actor;
Phase 02c the source each adapter writes and the admin query's permission
key; Phase 03 the Platform-scope gate with the killswitch writer, and the
PiiSensitive decision on tenant settings; Phase 06 the tenant-flag cache
invalidation the corpus had called a generation key; Phase 11 dependency
automation, locked restores and pool sizing. The Packet 9 record gains the
round, and the catalogue its new rules and counts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Audit Coverage now holds the canonical list of plain classes the capture
predicate was blind to, and says a document naming them links there rather
than counting them. Two carriers still gave the old counts - the
add-tenant-owned-entity skill said six, the glossary's Audit Capture
Pipeline entry five - and four others counted correctly but would drift
with the next plain class. All six now link to the list instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three statements were false when they entered the record, each now carries
an inline erratum and a dated amendment. ADR-0033 Amendment 7: a request
refused at validation, step 1, writes no audit row - the audit step runs
third - so Amendment 2 section 7 and Amendment 5 section 1 listed a shape
that never reaches the standalone writer. ADR-0044 Amendment 7: section 7
counted five plain entities classified MUST across two matrices; there
were six, four of them MUST, and only the Tenancy matrix names the host
mapping. ADR-0045 Amendment 4: of the projection's names, PlanCode differs
from the wire and ExpiresAt from the column. ADR-0023's Amendment 9, which
this pull request had put below References, moves above them. Every
decision is unchanged.

ADR: 0033, 0044, 0045, 0023
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cemililik
cemililik merged commit 42fee94 into main Sep 11, 2026
9 checks passed
cemililik added a commit that referenced this pull request Sep 11, 2026
Pull request #18 merged on 2026-09-11. The Packet 9 record's count was
measured at close and is now the count at merge - the merge's own CI run
matched it assembly by assembly - and the root README's shipped-packets
list, which named Packet 9's work while still ending at Packet 8, now
includes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant