Phase 02a Packet 9 — audit infrastructure and the entitlement socket - #18
Merged
Merged
Conversation
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>
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 SDKbackend/global.jsonadmits; CI installs exactly the pinned one.
What ships
The audit write path, end to end.
audit_logandaudit_configwith threeappend-only layers; the change-tracker capture;
PostgresAuditStore's four writes; themerged
IAuditCatalog; and the two pipeline steps that use them —AuditLogBehaviorclassifies and parks intents at step 3 and reconciles in its
finally,TransactionBehaviorflushes the MUST-class rows on the owning frame immediately beforeCOMMIT. With them the observable half of the fail-closed rule thatADR-0033 Amendment 3 assigns here: the
audithealth check, two counters, and aCriticalline.Two writers outside the pipeline.
PlatformAdminScope.EnterAsyncrecords its ownsecurity-eventrow in a transaction of its own that commits before the operation'sbegins — so work that later fails cannot erase the record of entering — and refuses entry
when it cannot.
AuditingTenantAssertionRecorderaudits a rejected tenantassertion 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.
IEntitlementProviderwithNullEntitlementProviderregistered in every deployment mode;
IFeatureFlagscomposing over the port ratherthan reading
platform_entitlement_cache, which is what makes swapping the providerchange the answer without touching module code; the three typed key registries; and
platform_killswitcheswith its policies, its overlay and its cache family — and nowriter, because every toggle runs inside
EnterPlatformAdminScope, whose registeredgate 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/26requires the limit floor to be "the Starter-tierdefaults compiled into the binary. Never
-1, never0", while the Hub's actual Starterrow carries
0for 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:
context.RequestAbortedreached
WriteStandaloneAsync, andTaskCanceledExceptionis not aDbException— sothe 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.
KillswitchOverlaycould not be resolved outside a transaction. It injected themodule
DbContext, whose factory throws when there is no ambient transaction — soresolving
IFeatureFlagsanywhere outside a unit-of-work frame threw before a singleflag 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.
and rolled back for both customization aggregates (a handler now designates its subject
through
IAuditSubject); successful rows carried no actor or correlation id; an innerrefusal 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.
root's own catalogue is now held to the discovered one);
IsLoadedwas trusted as proofof a complete collection, which a filtered
Includebreaks; aggregate-only modules andunreadable matrix rows escaped their guards; the documented frame lifecycle differed from
the code.
the rest; an unrecognised operation-type spelling skipped the comparison; the forward
join accepted a row in another module's matrix.
f54fc7b) — two raw projection reads left tenantisolation to row security alone (both now name the tenant, with cases that read as a
role row security does not bind);
audit_log.entity_idwasvarchar(100)while a hostmapping's key admits 253 (a new migration makes it
text); three recipes for laterphases 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.
cb2d5a5) — three blockers:audit_config's unique index countedsoft-deleted rows, so the first change of any override would have failed
23505forgood (a new migration makes it partial, and a new rule sweeps every context for the
shape); no
503carriedRetry-After(both Problem Details paths now send 30 s); and avalidation 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
503for a programmer error; a sentinel draft wasreported as a failed write; a store failure raised two
Criticallines; the canonicalsame-transaction rule's second clause had no case (one now forces the failure with a
trigger —
xmincannot prove it, because EF Core saves under a savepoint); a plan keywas never read against a conflicting tenant row; and
dotnet testexits0when itsfilter 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=directnever 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.jsonpins SDK 10.0.112, which both CI jobs install;No_Set_Based_Write_Bypasses_The_Audit_Capture, becauseExecuteUpdate/ExecuteDelete/
ExecuteSql*write rows the capture never sees; and the unresolved-entitlement counterloses 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
IUsageReportera soft limit can report to), andIEntitlementAdminQuery(Phase 02c). No killswitch or entitlement permission key isreserved 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 requiredcheck, and the
metacheck is still required under its pre-rename name.🤖 Generated with Claude Code