From b0449fefb1e6b5a63444e15a73d1697bb80f9a42 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sat, 5 Sep 2026 19:11:58 -0500 Subject: [PATCH] docs(adr): options memo for audit_log retention levers (BACKLOG #1421) ADR 0185, Proposed. #1421's last limb is one owner ruling: which retention lever exists for a table that must stay tamper-evident. This lays the options out and picks none. Mechanism re-read at HEAD, not inherited: the row id is not in the hashed payload, and the walk chains from the STORED hash. A verifier exists three ways and ships off. Eight shapes driven with both controls firing, plus a full walk beside the shipped one, which reports only the first divergent row. Three findings are new: a break is local and does not spread; an interior delete leaves the anchor head byte-identical; a tombstone keeping row_hash still breaks at that row. Crux: delete-then-reseal verifies clean, so only a held off-box anchor tells a purge from an attack. Docs only. No purge, no config field, no default moved, row stays open. Co-Authored-By: Claude Opus 5 --- docs/BACKLOG.md | 2 + ...each-deletion-shape-costs-verifiability.md | 250 ++++++++++++++++++ docs/adr/README.md | 1 + 3 files changed, 253 insertions(+) create mode 100644 docs/adr/0185-retention-levers-for-the-tamper-evident-audit-log-what-each-deletion-shape-costs-verifiability.md diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 97ea37070..9c69c915b 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -22088,6 +22088,8 @@ The author identified this failure mode precisely, named its consequence exactly > **Scored 2026-09-03 -> P1.** Value **6/10** · Difficulty **2/10** · _quick win_. All four costs stand at HEAD and none has been closed. Nothing bounds audit_log: [retention].audit_days defaults 0 and is documented reserved and unenforced at messagefoundry/config/settings.py:1675-1678, messagefoundry/store/base.py declares eight purge entry points between :1202 and :1849 with none for audit, and a grep of messagefoundry/ finds no reader of audit_days outside those two files, so an operator who sets a window gets no purge and no warning -- the accepted-but-inert knob is the gap, and the only workaround is flipping the #1277 default back off, which lowers the rate without bounding the table. The three records still disagree about why: messagefoundry/config/retention_classification.py:17-19 rests the rationale on the retention requirement "not on chain-breakage" while messagefoundry/config/settings.py:1675-1677 and docs/PHI.md:118 give both reasons, and that question decides whether in-place deletion is open at all. Cost 3's docstring is unchanged at messagefoundry/api/security.py:177-179, and ADR 0118's amendment at docs/adr/0118-secure-by-default-security-configuration-section.md:165 still records the outcome and the delegation without the two questions or the eight options. What this row ships is an owner ruling plus reconciled prose across five artifacts, so the cost is doc work held consistent rather than engine change. > > **Update 2026-09-03 -- the two limbs that need no ruling shipped; this row STAYS OPEN on costs 1 and 2.** **Cost 4 is closed.** ADR 0118's section 5 amendment now carries both questions put to the owner, all eight options and both answers quoted, with [comment 5515263760 on PR 749](https://github.com/MEFORORG/MessageFoundry/pull/749#issuecomment-5515263760) kept as provenance, so the record sits in the artifact a reader consults rather than only in a pull-request comment. **Cost 3 is closed.** The `_audit_all_authz` docstring in [`api/security.py`](../messagefoundry/api/security.py) no longer argues that a wider fallback would invent a grant row -- both call sites read it only after authorization has already succeeded, which the change states -- and it now gives the real reason, that the fallback preserves prior behaviour for a hand-built `app.state`. The behaviour did not change and both sides stay pinned in [`tests/test_auth_hardening.py`](../tests/test_auth_hardening.py). **Costs 1 and 2 remain OPEN**, and so does this row: they wait on the owner ruling this row's verdict names -- whether deleting an `audit_log` row breaks the tamper-evident hash chain -- because that answer decides whether in-place deletion is available at all and therefore which lever can be sized. Nothing was chosen or built for them here: no retention bound, no rate or sampling bound, no audit purge entry point, no group-committer change, and `[retention].audit_days` is still accepted and unenforced. +> +> **Update 2026-09-05 -- the options memo the ruling needs now EXISTS. The ruling is still OUTSTANDING and this row STAYS OPEN.** [ADR 0185](adr/0185-retention-levers-for-the-tamper-evident-audit-log-what-each-deletion-shape-costs-verifiability.md) lays out the retention levers so the owner can choose, and deliberately chooses none of them. It re-establishes the mechanism from the code at HEAD rather than inheriting it: the line numbers this row cites are from `fd44b0f17` and have since drifted, so read the ADR's. `audit_row_hash` is at `store/store.py:1009` and its payload at `:1057`; the row `id` is not in it, and the walk chains from the STORED hash at `:7998`, which is the line that decides most of the question. A verifier exists and is reachable three ways, and `[integrity].audit_verify_on_start` still ships `False` (`config/settings.py:3370`). **Eight shapes were driven at `c57903c2c` with both controls firing**, alongside a second full walk beside the shipped one because the shipped verifier returns only the FIRST divergent row. That reproduces this row's cases A, B and D and adds **three findings this row does not carry**: a break is LOCAL and does not spread, so every row after the damaged one still verifies and keeps its evidentiary value; deleting an INTERIOR row leaves the anchor head byte-identical, so an anchor catches that shape by its COUNT and not by its hash; and a tombstone that preserves the stored `row_hash` still breaks the walk at the tombstoned row, so preserving the link bounds the damage without removing it. **The crux for the ruling:** a delete-then-reseal verifies clean, which is what an attacker who can write the table would produce, so a re-sealing purge would leave a held off-box anchor as the only working control. Six levers are costed against chain, build and operator, and the ADR carries a Builder recommendation marked as separable from the findings. **Nothing was chosen and no engine behaviour changed**: no purge, no new config field, no default moved, and `[retention].audit_days` is still accepted and unenforced. **Costs 1 and 2 remain OPEN.** > Verdict: owner-ruling > Research: none > Closing-act: owner-ruling diff --git a/docs/adr/0185-retention-levers-for-the-tamper-evident-audit-log-what-each-deletion-shape-costs-verifiability.md b/docs/adr/0185-retention-levers-for-the-tamper-evident-audit-log-what-each-deletion-shape-costs-verifiability.md new file mode 100644 index 000000000..4ada97792 --- /dev/null +++ b/docs/adr/0185-retention-levers-for-the-tamper-evident-audit-log-what-each-deletion-shape-costs-verifiability.md @@ -0,0 +1,250 @@ +# 0185 — Retention levers for the tamper-evident `audit_log`: what each deletion shape costs verifiability + +- **Status:** Proposed — this is an options memo, not a decision. It waits on the owner ruling + [BACKLOG #1421](../BACKLOG.md) names. +- **Date:** 2026-09-05 +- **Related:** BACKLOG #1421 · BACKLOG #1277 (the grant-trail default) · BACKLOG #190 (chain keying) · + BACKLOG #328 (the prefix comparator) · [ADR 0118](0118-secure-by-default-security-configuration-section.md) §5 · + [ADR 0150](0150-client-address-on-audit-entries.md) · [CONFIGURATION.md](../CONFIGURATION.md#retention) + `audit_days`, the source of record for the chain-truncation reasoning · [PHI.md](../PHI.md) §2, §7, §8 + +--- + +## Context + +`[security].audit_all_authorization_decisions` ships `true`. On the shipped default, every +authenticated request on a `require()`-gated route writes one `auth.permission_granted` row. Nothing +prunes that table: `[retention].audit_days` defaults `0` and is accepted but never enforced +(`config/settings.py:1683`), and the `Store` protocol carries no audit purge at all. + +**Severity is conditional, per [CLAUDE.md](../../CLAUDE.md) section 0.** MessageFoundry is a +not-deployed beta with zero instances. Nothing is growing today. A first deployment *would* grow the +table for the life of the instance, one row per authenticated read, with no configured window able to +stop it. + +BACKLOG #1421 records four costs and picks no fix. Three pull requests have landed against it +already, and this memo does not re-derive their work: + +| PR | What it settled | Verified on `main` by content | +|---|---|---| +| 766 | Cost 4 — [ADR 0118](0118-secure-by-default-security-configuration-section.md) §5 now carries both questions put to the owner, all eight options and both answers. Cost 3 — the `_audit_all_authz` docstring no longer argues a wider fallback would invent a grant row. | `api/security.py:175-188` carries the replacement reason (preserving prior behaviour for a hand-built `app.state`) and names it a compatibility argument. The word "inventing" is still in the file, quoted as the retired reasoning it corrects — so a bare grep for it would read as "the fix did not land", which is the wrong answer. | +| 775 | The measurement: which deletion shapes break `verify_audit_chain`. | `docs/BACKLOG.md` row 1421 carries the six cases. | +| 805 | The rationale disagreement. `docs/CONFIGURATION.md`'s `audit_days` row is the source of record; `settings.py`, `retention_classification.py` and `PHI.md` §2/§7/§8 now link to it instead of restating it. | `settings.py:1675-1683` carries the corrected comment; `CONFIGURATION.md:750` carries the archive contract. | + +This repository squash-merges and deletes branches, so `git merge-base --is-ancestor` returns false +for work that landed. Each row above was checked with `git grep` against `origin/main` for text the PR +added, and for text it removed. + +**What is left is one decision.** Which retention lever exists for a table that must stay +tamper-evident. This memo lays out the options and their costs so the owner can choose. + +## The chain is a hash over the previous row's *stored* hash, and nothing else orders it + +`audit_row_hash` (`store/store.py:1009`) builds the payload at `:1057`: + +```python +fields: list[object] = [prev_hash, ts, actor, action, channel_id, detail] +if client is not None: + fields.append(client) +canonical = json.dumps(fields, sort_keys=True, default=str) +``` + +It digests those bytes three ways (`:1062-1066`): an isolated-module MAC when one is supplied, else +HMAC-SHA256 when a store key is set, else keyless SHA-256. + +**The row `id` is not in the payload.** The only link between two rows is the earlier row's stored +`row_hash`, folded in as `prev_hash`. `record_audit` (`:7603`) reads that head under the store lock +immediately before it inserts: + +```python +cur = await self._db.execute("SELECT row_hash FROM audit_log ORDER BY id DESC LIMIT 1") +last = await cur.fetchone() +prev = last["row_hash"] if last and last["row_hash"] else "" +``` + +So chain order is `id` order and nothing else. `row_hash` is a plain nullable `TEXT` column +(`store/store.py:1782`), writable by anyone who can write the table. + +The verifier walks in `id` order and chains from the **stored** hash, not the recomputed one +(`:7998`): + +```python +# Chain from the STORED hash (not `expected`) so a divergence is reported once, at its own +# row, instead of cascading a false break onto every successor. +prev = r["row_hash"] or "" +``` + +That one line decides most of what follows. + +## A verifier exists, it is reachable three ways, and it ships off + +This is not a "would fail" claim with nothing behind it. `verify_audit_chain` is on the `Store` +protocol (`store/base.py:1543`), implemented on all three backends, and reachable by: + +1. **The CLI.** `messagefoundry audit-verify` and `messagefoundry audit-anchor` + (`__main__.py:617` and `:646`). +2. **The engine at startup**, behind `[integrity].audit_verify_on_start`, which ships `False` + (`config/settings.py:3370`). It is alert-only when on: a broken chain logs and alerts, never + crashes startup. +3. **The API**, through the same protocol method. + +It takes no input but the database. Two optional arguments sharpen it: `expected_anchor`, an exact +`(count, head)` seal from `audit_anchor` (`store/store.py:7910`), and `expected_prefix`, the #328 +comparator. **Both must be held out-of-band.** Nothing in the engine stores an anchor for you. + +The walk returns one boolean for the whole log and names the **first** divergent row +(`store/store.py:8008`): `audit chain broken at row id=N`. + +## What a purge would actually do, measured + +Driven on this branch at `c57903c2c`: a throwaway SQLite store in a temp directory, six rows written +through the real `MessageStore.record_audit`, each shape applied out-of-band with plain SQL, then +`verify_audit_chain` re-run. Synthetic actors only. Two controls: a clean store, which must verify, +and an ordinary interior edit, which must break. + +Beside the shipped verifier I ran a second walk that reports **every** divergent row, because the +shipped one returns only the first. That is what shows whether a break spreads. + +| Case | What happened to the rows | `verify_audit_chain()` | Rows that actually mismatch | Against a held anchor | +|---|---|---|---|---| +| control | nothing | `True`, 6 rows | none | `True` | +| control | edit row 3's `actor` | `False` at id=3 | 3 | `False` | +| A | delete interior row 3 | `False` at id=4 | 4 | `False` | +| B | delete the two oldest | `False` at id=3 | 3 | `False` | +| D | delete the two newest | **`True`, 4 rows** | none | `False` — count and head both moved | +| E | tombstone row 3, keep its `row_hash` | `False` at id=3 | 3 | `False` | +| F | tombstone the two oldest, keep their `row_hash` | `False` at id=1 | 1 and 2 | `False` | +| G | delete the two oldest, then re-seal every survivor | **`True`, 4 rows** | none | `False` — head moved | + +Both controls fired, so the instrument can say both things. + +Four findings follow, and three of them are not on `main` today. + +**A break is local. It does not spread.** In every broken case, only the rows I touched mismatched. +Their successors verified. Case A deleted row 3 and only row 4 diverged; rows 5 and 6 still chained +cleanly. This falls straight out of the `prev = r["row_hash"]` line above. It matters because it means +a purge does not destroy the evidentiary value of the rows that survive — but the shipped verifier +reports one boolean, so the whole log reads broken forever regardless. + +**Deleting an interior row does not change the head.** Case A's anchor head was byte-identical before +and after; only the count fell, from 6 to 5. So an anchor catches an interior delete by its **count**, +not by its hash. An anchor comparison that checked only the head would pass over it. + +**A tombstone that preserves the link still breaks the walk.** Cases E and F kept each row's stored +`row_hash` untouched and only redacted its content. The walk recomputes each row's hash from its own +content, so it diverges at the tombstoned row. The successors verify, because they chain from the +preserved stored hash. So preserving the link bounds the damage, and does not remove it. + +**A re-seal verifies clean, and only an anchor tells it from an attack.** Case G deleted the two +oldest rows and recomputed every surviving `row_hash` from an empty `prev`. The result verifies +`True`. A bare `audit-verify` reports a healthy four-row chain. The only thing that catches it is an +anchor held off-box, whose head no longer matches. + +**That is the crux of the whole decision.** A re-seal is exactly what an attacker who can write the +table would do. If the engine ships a purge that re-seals, the verifier can no longer tell a +configured retention pass from a cover-up, and the anchor becomes the only control that still works. + +## The options, and what each costs + +"Chain cost" is what the shipped verifier would report after the lever ran. + +| Lever | What it does to the chain | Build cost | Operator cost | +|---|---|---|---| +| **Hard delete (in-place age window)** | Breaks it permanently at the first surviving row, with the same sentence a tamper produces. Nothing heals it: `_backfill_audit_chain` (`store/store.py:2454`) fills only NULL hashes and skips any row that has one, so a reopen cannot repair a purge. | Low. One purge method plus a `RetentionRunner` phase. | High and permanent. Every verify fails forever. The alarm an operator most needs is now stuck on. | +| **Tombstone preserving the link** | Breaks at each tombstoned row; successors verify. Making it chain-neutral would need the verifier to accept a marked row on its stored hash alone — which hands an attacker a way to edit any row by marking it. | Medium for the write, high for a safe verifier change. A tombstone would need its own authenticated form. | Medium. Verify reports a known, bounded break, so operators must learn to read a `False` that is expected. | +| **Time-based partitioning, no deletion** | None. Rows stay, so the walk is untouched. | Medium. Partitioned tables differ across SQLite, Postgres and SQL Server, and `verify_audit_chain` walks one ordered set. | Low, but it does not bound the table. It moves storage, it does not reclaim it. | +| **Archive-and-reseal (signed summary replaces the span)** | Verifies clean afterwards — see case G — which is the problem. It destroys the evidence that anything was removed, so the summary's signature becomes the only proof, and a held anchor becomes mandatory rather than optional. | High. A signing key with its own custody, a summary format, and a re-anchor step. | High. Whoever holds the anchor now holds the whole integrity claim. | +| **Archive-first, restore-capable (no reseal)** | None while the rows are present. The purge is the delete, so it carries the hard-delete cost — unless the archive can be restored, which case C1 in #1421 showed requires the original `id` and `row_hash`, not just row content. | Medium. An export format that carries `id` and `row_hash`, plus a restore path. | Medium. The archive must be kept for the full retention period, which is what the requirement asked for anyway. | +| **A write-time bound (sample or aggregate the read-grant rows)** | None. It deletes nothing, so the chain constrains it not at all. | Low to medium. It changes what `require()` records, not the store. | Low. No new artifact to hold, no verify to interpret. | + +The write-time bound is the one lever whose cost is not paid in verifiability. Its cost is paid in +completeness of the grant trail, which is the property BACKLOG #1277 turned on deliberately. + +Two things this table deliberately leaves out. **Enrolling audit in the ADR 0055 group committer** is +cost 2 of #1421, not a retention lever: it changes commit batching, not row count. The chain does set +one constraint on it — each hash folds the previous row's stored hash, so the head read and the insert +must stay serialized in `id` order, which the standalone commit inside the lock gets for free today +(`store/store.py:7650`, and the committer excludes audit by name at `:2109`). **Turning +`audit_all_authorization_decisions` back off** is not a retention lever either. It lowers the write +rate without bounding the table, and it is reported as a loosening. + +## What each option does to the PHI position + +The privacy map is [PHI.md](../PHI.md); this section only records where the options differ, and does +not restate it. + +`audit_log` rows are metadata, not bodies. `detail` holds filter shapes, counts and ids +([PHI.md](../PHI.md) §2). It is stored in the clear — it is not a cipher-covered column — and +`client` is stored in the clear by decision, so it stays greppable for incident response. + +Three differences matter to a ruling: + +1. **An audit row records who read what.** Keeping it longer is better for accountability and worse + for the subject, because the access record itself is personal data about the reader. Deleting it + is the reverse. The retention requirement (45 CFR 164.316(b)(2)(i), about six years) already + settles which way that trade goes, and it points at keeping. +2. **Archive-first moves PHI-adjacent metadata outside the store's controls.** The store's protections + are its ACL and the volume layer. An archive file inherits neither unless the operator supplies + them. Any archive option needs that said in the runbook, or it quietly widens exposure while + looking like a retention win. +3. **A write-time bound is the only option that reduces the amount of access metadata written at + all.** It shrinks the record rather than relocating or deleting it. That is better for the reader's + privacy and worse for the trail an investigator would want. + +The off-box tee is unaffected by every option here. It emits one PHI-safe JSON object per committed +row (`store/audit_tee.py`), after the commit and outside the lock, so a copy of the trail survives +whatever happens to the table. That copy is not chained. + +## Options considered + +This ADR chooses nothing. The six levers above are the options, and the ruling is the owner's. + +## Recommendation — the Builder's, and separable from the findings above + +Everything above this heading is measured or quoted. This section is my judgment and the owner may +take it or leave it. + +**Order the levers this way.** + +1. **Make the anchor a real operational step first, before any deletion lever ships.** Every safe + deletion option depends on an anchor held off-box, and today nothing holds one: + `audit_verify_on_start` ships `False`, and `audit-anchor` exists with no runbook step that calls + it. Shipping a purge before the anchor is routine would remove the one control that could tell a + retention pass from an attack. This is the cheapest item here and it unblocks the rest. +2. **Take the write-time bound as the primary lever.** It is the only one that costs verifiability + nothing. The table's growth problem is caused by writing one row per authenticated read, and the + honest fix for writing too much is to write less — not to damage the property the table exists to + have. A sampling or aggregating rule over `auth.permission_granted` reads, with state-changing + actions never sampled, keeps the trail that matters intact. +3. **If a bound on the table itself is still required, use archive-first with the case C1 contract, + and never re-seal.** The export must carry each row's `id` and `row_hash`, and a restore must land + them back at their original ids. Accept that a bare verify reports broken afterwards, and give + operators a documented way to verify the archive plus the live tail together. +4. **Reject hard delete and reject archive-and-reseal.** Hard delete leaves a permanent alarm that + reads exactly like a tamper, which trains operators to ignore the alarm. Re-seal is worse: it + leaves a clean-verifying chain that an attacker could produce identically. + +**One thing I would change regardless of the ruling.** `verify_audit_chain` returns a single boolean +for the whole log while the underlying break is local. An operator cannot currently tell "one old row +went" from "the log is compromised". Reporting the divergent row ids and the length of the clean +suffix would cost little and would make every option above easier to run. That is a separate item and +I have not filed it. + +## Consequences + +**Positive** — the retention question now has the costs written down beside it, so a ruling can be +made once instead of re-derived per lever. + +**Negative / risks** — this memo adds a fifth record touching the `audit_days` rationale. It links to +[CONFIGURATION.md](../CONFIGURATION.md#retention) rather than restating it, per SDS-3.5, because four +copies of that reasoning is exactly how the records drifted apart before PR 805 reconciled them. + +**Out of scope** — cost 2 of #1421 (the standalone commit per authenticated read), any change to +`[security].audit_all_authorization_decisions`, and any engine behaviour at all. Nothing is built +here. + +## To resolve on acceptance + +- [ ] The owner picks a lever, or rules that no bound ships and the table stays keep-forever. +- [ ] If a deletion lever is chosen, decide whether the anchor becomes mandatory before it ships. +- [ ] File the follow-up for a richer `verify_audit_chain` result, if the owner wants it. diff --git a/docs/adr/README.md b/docs/adr/README.md index d275fbab0..8d15d7186 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -202,3 +202,4 @@ what is withheld and what you can request. | [0179](0179-front-loaded-time-amortised-subtree-re-resolution-in-the-connscale-fd-probe.md) | **Front-loaded, time-amortised subtree re-resolution in the connscale FD probe** (BACKLOG #1357) -- `FdSampler` re-walked the engine's process subtree every `_RESOLVE_EVERY_TICKS = 8` sample ticks, and its own comment reasoned about that number in SECONDS (*"at the runner's poll cadence this re-checks the topology every few seconds"*). **A tick is not the poll interval** -- it is the interval PLUS the probe's own shell-out, measured at 0.21-1.19 s against a 0.25 s interval, so a tick ran 3-5x the unit the constant was reasoned in. The result is arithmetic, not statistical: a real `run_connscale` sweep at the CI cell's cadence (hold 1.5 / poll 0.25) reported `fd_probe_ticks = 2` on **all four** steps against the 8 the gate needed, so **0 of 4 could re-resolve**; a hold ladder crossed over only at ~6 s, and both shipped smoke profiles sit at 3.0. **Where the one walk lands is what makes an arbitrary number look plausible:** the runner builds a fresh sampler per sweep step and `messagefoundry/pipeline/sandbox.py` spawns the sandbox worker child lazily on first dispatch, so the single walk fires at tick 1, mid-ramp, while workers are still appearing -- and `handles_peak` is a plain `max()` with no PID-set predicate, so it is not degraded to a gap, it is a plausible number for the wrong process set. Decision: **two triggers, and drop the tick unit rather than layer over it** -- the first `_FRONTLOAD_WALKS = 4` ticks of a sampler's life each re-walk, then amortise on `_RESOLVE_INTERVAL_S = 5.0` SECONDS. The front-load is bounded by walk COUNT so it starts at the first SAMPLE rather than at construction, and however long the connection ramp takes it still covers the beginning of the measurement window; both counters advance for an ATTEMPT so a failing enumeration cannot re-walk flat out. Both call sites construct the sampler bare, so the default change reaches them with no plumbing. **Changes what `handles_peak` MEANS** -- a reading taken before is not comparable with one taken after. Rejected: lowering the tick count (one tick costs longer than the window, so the unit is wrong at any value); `resolve_every=1` (correct but 1056-1278 ms per walk against 210-370 ms cached would let the probe rather than the profile set the cadence); time-based alone (fixes the unit, never fires inside a 1.5 s hold); and staleness from `cpu_pids` (cheap, and detects DEPARTURES only -- a newly spawned worker can never appear in a read keyed to a stale PID list, and arrival is this defect's case) | **Accepted (2026-08-28)** -- built with the change, proven red-first: the acceptance test drives a PRODUCTION-constructed sampler over the 2-tick budget a step affords against a tree that grows after construction, and reds naming 6 of 7 live descendants missed. Three mutants killed on disjoint tests, each reverted byte-identical by SHA-256. Post-fix on the same rig the CI-cell arm re-resolves inside the window (PIDs 6 to 14, handles 438 to 1016) while the amortisation control made 7 walks over 30 ticks, not 30. **A walking tick costs more, so fewer fit:** end to end the CI cell's `fd_probe_ticks` went 2/2/2/2 to 1/1/1/2, every step still measured, both groups still carry the two readings `fd_count_monotonic` needs -- and where a window affords ONE tick no trigger can see growth, which is a property of `hold_seconds` and is NOT claimed fixed here. **No product axis** -- an instrument, not shipped engine behaviour (CLAUDE.md section 0); the cost is that the resource posture of a multi-process engine cannot be measured, which is what holds #1278. The ~3745-vs-~385 handle figure and every per-worker number derived from it stay WITHDRAWN: the caught PID count varied 2, 3, 8, 50 across consecutive ticks of one run | | [0180](0180-asserting-tls-suites-on-a-library-that-exposes-no-sslcontext.md) | **Asserting TLS suites on a library that exposes no SSLContext** (BACKLOG #1317) -- the remainder of #1317, whose row states it exactly: ldap3, hvac and ODBC Driver 18 "choose their own suites and no engine object exists to assert on". Measured with one instrument on two real call sites: the HTTP-family opener reached `harden_cipher_suites` **1** time while `LdapAuthenticator._server()` reached it **0**, and an `ldap3.Tls` was found to carry **zero** `SSLContext` attributes -- it builds the context inside `wrap_socket` at connect time and exposes no `ssl_context=` to inject one through. The suite list that hop resolves to is CLEAN today (17 suites, zero NULL/anonymous/non-forward-secret), so the defect is **inheritance without assertion**, not a negotiable weak suite. Decides the general question: **where a library exposes no context, assert a REBUILT one and pin the rebuild by capturing the library's own** -- a test drives ldap3's real `wrap_socket` over a socketpair and compares -- and **REFUSE any argument the rebuild cannot reproduce**, because `ldap3.Tls(ciphers=...)` is a measured trap: `except ssl.SSLError: pass` swallows a rejected string and silently strips the TLS 1.2 list from 14 suites to 0. Scopes the other two OUT with reasons rather than deferring them: **hvac is unmeasurable here** (hvac/requests/urllib3 all absent, and NO CI leg installs the `[vault]` extra), so a control there would be one no test in the project can execute; **ODBC Driver 18 is out permanently** (`store/sqlserver.py` contains no `ssl` usage at all -- TLS is connection-string keywords terminated in the native driver, so no replica is even possible). | **Accepted (2026-08-28)** -- built with the change; the AD LDAPS bind is the seventh asserted site and the first asserted via a rebuilt context. Red-first: `test_ad_ldaps_bind_asserts` failed DID NOT RAISE before the wiring. Five mutations each red a distinct test and all files restored byte-identical; deleting the call reds two tests while drifting `_server()` off `_tls_kwargs()` reds only the argument test, so the two are not one case wearing two names. 137 passed on the #1317 baseline (129 before, +8 new), 572 on the auth/TLS/settings slice, mypy strict clean with no new errors. Severity conditional per CLAUDE.md section 0 -- nothing is intercepted today; a deploying site WOULD cross an unasserted context on its AD bind | | [0182](0182-split-the-account-mirror-address-from-the-engine-owned-notification-address.md) | **Split the account mirror address from the engine-owned notification address** (BACKLOG #1139, ASVS 6.3.7) -- one nullable column, `users.email`, was simultaneously the directory's MIRROR (rewritten from the `mail` attribute by `_upsert_ad_user` on every AD/OIDC login) and the TARGET every out-of-band security notice is addressed to. Two things follow from that single fact. **A directory repoint would replace the very address the notice about it has to reach**, which is why #1139's research question -- *"to which address, given the old value is the only one the engine can still reach and the same operation is replacing it?"* -- reads as unanswerable: the premise is wrong, not the answer hard. **And a clear was permanent exclusion**, because `SecurityEventNotifier.notify` opens `if not event.email: return`. Decision: `users.notify_email`, engine-owned, named by NO directory-sync statement -- `update_user_profile`, the one call the AD upsert makes against an existing account, still writes `display_name` and `email` and nothing else. Seeded ONCE at account birth (there is exactly one address at that instant and no reason for the two to differ); thereafter written only by `set_user_notify_email`. **THE DURABILITY RULE IS THAT SETTER'S SIGNATURE, and it is the limb the item says the build owes itself:** `email: str`, not `str | None`, so a clear is unrepresentable at every call site, plus a refusal of the whitespace-only string that would mean the same thing -- repointable but not erasable. Making an address mandatory at creation would NOT have achieved this, since an explicit null still strips it afterwards. **NOT NULL was the other offered route and is rejected**: accounts may still be created with no address (the first-login set-address step is a follow-on), so it would need a placeholder the notifier treats as absent anyway -- a constraint that reads as a guarantee and delivers none. Also rejected: a provenance flag on one column (a second column doing the split's job with less clarity); notifying the directory's NEW address (whoever repointed the attribute is the party it would reach); and any dual-read, shim or deprecation window, per CLAUDE.md section 0. `has_notifiable_admin` and the PHI startup gate move to `notify_email` too -- asking about `email` would be the instrument answering the adjacent question (SDS-3.8). Two pieces of prose the change FALSIFIES are corrected in the commit that falsifies them, the SDS-3.7 shape: the notice body's promise that a cleared address receives nothing further, and the startup gate's docstring claim that a human-set address is overwritten at the next directory sign-in | **Accepted (2026-09-03)** -- built with the change under an owner ruling of that date; three store backends plus the protocol. Seven new tests proven red-first against `origin/main` source (4 store, 3 service), including a negative control that an admin can still REPOINT, so a no-op setter cannot pass. Postgres and SQL Server run on hosted runners only, so **CI is the authority for those two backends**; the SQLite leg is exercised locally including a real pre-split table with the column dropped. Stacks on PR 759, which landed #1139's write-path limb and is unmerged. Severity conditional per CLAUDE.md section 0 -- **zero deployments**, so no account's notices are misdirected today; a deploying site with AD enabled WOULD inherit both defects | +| [0185](0185-retention-levers-for-the-tamper-evident-audit-log-what-each-deletion-shape-costs-verifiability.md) | **Retention levers for the tamper-evident `audit_log`: what each deletion shape costs verifiability** (BACKLOG #1421) -- an options memo, deliberately choosing nothing. #1421's remaining limb is one ruling: which retention lever exists for a table that must stay tamper-evident. Establishes the mechanism from the code rather than from reasoning about audit logs in general -- `audit_row_hash` (`store/store.py:1009`) digests `[prev_hash, ts, actor, action, channel_id, detail]` plus a conditional `client`, the row `id` is NOT in the payload, and `record_audit` reads the head under the store lock, so chain order is `id` order and nothing else. A verifier exists and is reachable three ways (the `Store` protocol, `messagefoundry audit-verify`/`audit-anchor`, and `[integrity].audit_verify_on_start`, which ships `False`). Eight shapes driven at `c57903c2c` against a throwaway store, both controls firing, with a second full walk beside the shipped one because the shipped verifier returns only the FIRST divergent row. **Three findings are not on `main`:** a break is LOCAL and does not spread (successors chain from the STORED hash, so surviving rows keep their evidentiary value); deleting an INTERIOR row leaves the anchor head byte-identical, so an anchor catches it by COUNT and not by hash; and a tombstone that preserves the stored `row_hash` still breaks the walk at that row, so preserving the link bounds the damage without removing it. **The crux:** a delete-then-reseal verifies clean, which is exactly what an attacker who can write the table would produce, so a re-sealing purge would leave a held off-box anchor as the only working control. Six levers costed against chain, build and operator -- hard delete, tombstone, partitioning, archive-and-reseal, archive-first restore-capable, and a write-time bound (the only one whose cost is not paid in verifiability). Separates two things that are not retention levers: the ADR 0055 group committer (#1421 cost 2) and flipping the #1277 default back off | **Proposed (2026-09-05)** -- awaiting the owner ruling #1421 names; no code, no engine behaviour change, no lever chosen. Severity conditional per CLAUDE.md section 0 -- zero deployments, so nothing is growing today; a first deployment WOULD grow the table unbounded at one row per authenticated read. Carries a Builder recommendation marked as separable from the findings: make the anchor operational first, take the write-time bound as the primary lever, use archive-first with the case C1 contract if the table itself must be bounded, and reject both hard delete and re-seal |