From 82ba0a341c9385869d7ccd06dedcb7af31870321 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 23 Aug 2026 13:01:45 -0500 Subject: [PATCH 01/10] backlog: rewrite #1244 against the ratified coverage boundary (owner ruling 2026-08-23) The constraint that made this item hard is retired. It required a public pre-merge check to name no cell id and no count of affected cells, on the reasoning that a public CI log enumerating them hands out coverage by subtraction. A workflow tested that premise against the public engine tree and inverted it: the aggregate coverage the constraint protected is already public in tracked engine files. The constraint was defending a number that was already out. The owner ratified that boundary rather than mounting a redaction campaign, and redrew the private set to three things: the per-cell verdict grid, the residual prose, and the absence claims. Two consequences follow and both delete work this item required: "must not name the count of affected cells" is retired, and "keep the anchor set secret" is dropped -- no public, actionable, pre-merge check naming a file and line can satisfy it, because such a check is a membership oracle by construction. Recorded alongside, so nobody reads permission into silence: the ruling ratified the aggregate and did not rule that a public log may print an id paired with its verdict. That pairing is the vaulted grid. Also in this commit: - Gap 3 is marked CLOSED, with the mechanism (a shrink-only anchor-failure baseline keyed on kind/path/token) and its CI-unverified, unpushed status. Gaps 1 and 2 stay open. - The mechanism is stated rather than lettered. It was never "(a) or (b)"; it is the vault-initiated check-run decided 2026-08-09. - The two conditions that still bind are written into the item: clear the broken anchors before wiring any gate, and settle required-context up front, since an external check-run is advisory unless branch protection names it. - The unmeasured limb is named as unmeasured. Whether anchor density correlates with non-pass verdicts was not measured by the ratification, and no new constraint may lean on it. - The re-scored banner's difficulty rationale cited the retired constraint. Flagged rather than re-scored; a re-score is its own dated act. Co-Authored-By: Claude Opus 5 --- docs/BACKLOG.md | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 2f583467..9c0cd99f 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -11819,7 +11819,7 @@ _FHIR_ID_RE.fullmatch("abc\n") -> False the fix ## 1244. an engine change that breaks a vault ASVS anchor produces no state change and no attribution: the signal is real, late, and lands in a gate that is already red -> 🔢 **Re-scored 2026-08-20 -> P2.** Value **6/10** · Difficulty **5/10** · _quick win_. No engine-side pre-merge anchor check exists: the engine carries scripts/asvs/scorecard.py and zero scorecard data (git ls-files docs/security returns 0), no asvs verify context appears anywhere in .github/required-contexts.txt (141 lines, one ASVS entry and it is the crypto-inventory gate at :79), and the two engine ASVS workflows do other jobs. All three limbs stand. Difficulty 5 because the design must hand a public repository enough to name the engine file and line while naming no cell id, and the blocking limb inherits the precondition of clearing the standing failures first. _(was 6/10 · 4/10.)_ +> 🔢 **Re-scored 2026-08-20 -> P2.** Value **6/10** · Difficulty **5/10** · _quick win_. No engine-side pre-merge anchor check exists: the engine carries scripts/asvs/scorecard.py and zero scorecard data (git ls-files docs/security returns 0), no asvs verify context appears anywhere in .github/required-contexts.txt (141 lines, one ASVS entry and it is the crypto-inventory gate at :79), and the two engine ASVS workflows do other jobs. All three limbs stand. Difficulty 5 because the design must hand a public repository enough to name the engine file and line while naming no cell id, and the blocking limb inherits the precondition of clearing the standing failures first. _(was 6/10 · 4/10.)_ **NOT RE-SCORED, BUT HALF THIS RATIONALE IS GONE: the owner RETIRED the "name no cell id / no count" constraint on 2026-08-23 (see the ratified-boundary block below), so the leak-avoidance half of Difficulty 5 no longer applies. The precondition half stands and now binds explicitly. Gap 3 is separately CLOSED. A re-score is warranted and is deliberately left to whoever next scores this item, rather than smuggled into an amendment.** > > **Filed 2026-08-13. NOT "nothing detects this" -- that framing was wrong and is recorded below so nobody re-derives it.** The detection already exists and works; what is missing is that it cannot **block**, cannot **attribute**, and currently cannot **change colour**. > **THE COUPLING.** The scorecard lives in the vault (private); the code it cites lives in the engine (public). Every one of the **1,998** evidence anchors names an engine path, and **192** of them cite engine *prose* under `docs/` -- the kind of line an ordinary documentation edit rewrites without anyone thinking about security evidence. Nothing in the engine repo references the scorecard, and nothing can: **the engine tree contains the verifier (`scripts/asvs/scorecard.py`) but zero scorecard data files**, by deliberate design, because the data is what must stay private. @@ -11830,15 +11830,38 @@ _FHIR_ID_RE.fullmatch("abc\n") -> False the fix > The engine PR is where the change was still reviewable. > 2. NOT ATTRIBUTED the run reports a broken anchor, not which engine change broke > it. Whoever reads it bisects a day of engine history by hand. -> 3. NO STATE CHANGE the gate is ALREADY RED -- 11 FAIL rows on the current run -- -> and `verify` is NOT a required context. So a 12th failure moves -> nothing anyone watches: not the check's colour, not -> mergeability, not a notification. +> 3. NO STATE CHANGE the gate is ALREADY RED and `verify` is NOT a required context, +> so one more failure moves nothing anyone watches: not the +> check's colour, not mergeability, not a notification. > ``` +> **GAP 3 IS CLOSED as of 2026-08-23** -- vault branch `asvs-1244c-baseline`, three commits, **CI-UNVERIFIED and unpushed**. A frozen baseline of the known-failing anchors, keyed on `sha256(kind, path, expect-token)` and permitted only to **shrink**, plus a checker and its own workflow job. A failure whose key is in the baseline is old news; **a failure whose key is not in it is the state change this gap asked for.** The line number is deliberately excluded from the key: the same run carried 1181 advisory DRIFT rows, and a line-keyed baseline would report every one of them as a new break. Two refusals are load-bearing -- an unparsed `FAIL` line is fatal, and a verifier that could not measure (exit >= 2) is fatal, because an empty result set compares clean against any baseline. **Gaps 1 and 2 remain open.** > **Gap 3 is the real defect.** A detector whose output is a count inside an already-failing, non-blocking job is indistinguishable from the same job yesterday. That is why the instance below was caught by **a person reading a diff**, not by the instrument that exists to catch it. > **THE INSTANCE THAT PROMPTED THIS.** Engine PR #361 deletes a sentence from `docs/SECURITY.md` that an anchor pins verbatim. The break was found by a session reading the PR's diff and reasoning about the vault; the retirement was prepared by hand as a separate vault change. Had nobody looked, #361 would have merged and the count would have gone 11 to 12 on a schedule, hours later, attributed to nothing. -> **A CONSTRAINT ON THE FIX THAT IS EASY TO MISS, and it is why the obvious build is wrong.** An engine-side check runs in a **public** repository with public logs. It must report **the engine file and line that stopped matching** and must **not** name the cell, the requirement id, or the count of affected cells -- those are the vaulted content, and a failing public CI log enumerating them hands out coverage by subtraction (CLAUDE.md section 12). "Report the line, never the cell" is a hard requirement of any design here, not a preference. This is what makes the item non-trivial: the natural implementation leaks. -> **How to prove a fix:** in an engine PR, delete a line that an anchor pins verbatim, and assert the PR goes red **before merge**, naming the engine file and line and **naming no cell id**. Then assert the negative control: a PR touching an unanchored line stays green, so the check is discriminating rather than always-red. A fix that only shortens the schedule addresses gap 1 and neither of the others; a fix that makes `verify` required **without first closing the standing 11 failures** converts a silent problem into a permanently blocked repository. +> **THE CONSTRAINT THAT MADE THIS ITEM HARD WAS RETIRED BY OWNER RULING, 2026-08-23. Read this before designing anything.** The paragraph replaced here required a public check to name **no cell id and no count of affected cells**, reasoning that a public CI log enumerating them hands out coverage by subtraction. **A workflow tested that premise against the PUBLIC engine tree and inverted it: the aggregate coverage the constraint protected is ALREADY PUBLIC in tracked engine files today** -- cell count, anchor counts, several verdict tallies, and seven named cell ids carrying verdicts and reasoning. The judge grepped the public tree and never opened the vault. **The constraint was defending a number that was already out.** +> **THE OWNER RATIFIED THAT BOUNDARY RATHER THAN MOUNTING A REDACTION CAMPAIGN, and redrew the private set to THREE THINGS:** +> ``` +> 1. the per-cell verdict GRID at requirement granularity +> 2. the residual prose +> 3. the absence claims -- especially `mutation` and `positive_control` +> (a `mutation` field is a worked edit that DEFEATS a shipped control) +> ``` +> **AGGREGATE COVERAGE IS PUBLIC AND RATIFIED.** Do **not** file defects against the tracked files carrying the tallies, and do not treat a count as a leak. +> **TWO CONSEQUENCES FOLLOW WITHOUT ASKING, and both delete work this item used to require:** +> - **"Must not name the count of affected cells" is RETIRED.** Design against the ratified boundary, not the old one. +> - **"Keep the anchor set secret" is DROPPED.** No public, actionable, pre-merge check naming a file and a line can satisfy it -- **such a check is a membership oracle by construction**, and a few dozen pull requests recovers the density map regardless. The requirement was unsatisfiable, not merely expensive. +> **WHAT THE RULING DID NOT SAY, recorded so nobody reads permission into silence.** It ratified the *aggregate*. It did not rule that a public log may print an id paired with its verdict -- **that pairing IS the vaulted grid, limb 1 above.** A bare ASVS requirement number is public by nature, since the standard is public; the id-to-verdict mapping is the private thing. Where the ruling is silent, it is silent. +> **ONE LIMB SURVIVES UNMEASURED: whether anchor density correlates with non-pass verdicts.** That correlation is the only thing that would make an anchor manifest a real subtraction leak, and **ratifying the boundary did not measure it.** Do not lean a new constraint on it, and do not record it as cleared. +> **How to prove a fix:** in an engine PR, delete a line that an anchor pins verbatim, and assert the PR goes red **before merge**, naming the engine file and line and **naming no verdict**. Then assert the negative control: a PR touching an unanchored line stays green, so the check is discriminating rather than always-red. A fix that only shortens the schedule addresses gap 1 and neither of the others. +> **THE MECHANISM IS NOT AN OPEN QUESTION, AND IT WAS NEVER "(a) OR (b)".** It is the **vault-initiated check-run**, decided in this repository on **2026-08-09**. Anyone carrying *"option (a) versus option (b)"* as the pending decision is carrying a retired framing -- **state the mechanism, not a letter.** +> **THIS DOES NOT AUTHORISE A BUILD. Two conditions bind, and the first is a hard precondition:** +> ``` +> 1. CLEAR THE BROKEN ANCHORS FIRST. Making a check required while failures +> stand converts a silent problem into a permanently blocked repository. +> 2. SETTLE REQUIRED-CONTEXT UP FRONT. An external check-run is ADVISORY +> unless branch protection names it -- so a gate can be built, be correct, +> and still change nothing anyone watches. Decide this before writing the +> check, not after it is green. +> ``` > ⚠️ **Recording the framing error, because it was mine and it was nearly filed.** This was first proposed as one item covering a whole class -- *"two artifacts that must agree with nothing noticing when they stop"* -- generalised from three sightings in one evening. That was wrong twice over. **A class has no prove-a-fix**, so it could never be closed, and bundling limbs with different fixes is exactly what splitting #1236 out of #1131 existed to prevent. And the premise did not survive contact: the detector exists, a daily run already covers the case, and only the blocking, attribution and visibility limbs are real. Two of the three original sightings did not survive at all -- one had resolved itself correctly, and the third was carried second-hand and never verified. On re-reading it was a **different mechanism** anyway (one instrument answering a narrower question than its declared scope, rather than two artifacts drifting apart); they share a smell, not a cause. > Verdict: build > Closing-act: code From 4bb6e539d17f63e905a32758d687d26320477a96 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 23 Aug 2026 13:07:36 -0500 Subject: [PATCH 02/10] backlog: record why the anchor manifest stays rejected after the coverage objection was retired The ratification removed the reason this option was filed as forbidden. Without a replacement, the item now reads as though the enumeration is back on the table -- the exact "silence is permission" reading the previous commit warned against, arriving one paragraph later. The replacement ground is stronger because it rests on no secrecy argument at all: a manifest publishes the one field the checker does not decide on. check_anchors says so in its own docstring at scripts/asvs/scorecard.py:944 -- a token that is GONE or AMBIGUOUS reds the gate, while a token that is unique and present but sits at a different line is an ADVISORY. The verdict turns on token presence and uniqueness; nothing branches on the line number. So publishing file-and-line pairs pays a real disclosure cost for a field carrying no decision weight, and it is also the field most likely to be wrong: one verifier run carried 1181 advisory DRIFT rows, anchors resolving at a line other than the one recorded. The manifest would be simultaneously the leakiest column and the stalest. Verified against the engine source rather than taken on relay. Co-Authored-By: Claude Opus 5 --- docs/BACKLOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 9c0cd99f..9dbc3e24 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -11853,6 +11853,8 @@ _FHIR_ID_RE.fullmatch("abc\n") -> False the fix > **ONE LIMB SURVIVES UNMEASURED: whether anchor density correlates with non-pass verdicts.** That correlation is the only thing that would make an anchor manifest a real subtraction leak, and **ratifying the boundary did not measure it.** Do not lean a new constraint on it, and do not record it as cleared. > **How to prove a fix:** in an engine PR, delete a line that an anchor pins verbatim, and assert the PR goes red **before merge**, naming the engine file and line and **naming no verdict**. Then assert the negative control: a PR touching an unanchored line stays green, so the check is discriminating rather than always-red. A fix that only shortens the schedule addresses gap 1 and neither of the others. > **THE MECHANISM IS NOT AN OPEN QUESTION, AND IT WAS NEVER "(a) OR (b)".** It is the **vault-initiated check-run**, decided in this repository on **2026-08-09**. Anyone carrying *"option (a) versus option (b)"* as the pending decision is carrying a retired framing -- **state the mechanism, not a letter.** +> **AND THE PUBLISHED-ANCHOR-MANIFEST OPTION IS STILL REJECTED -- ON A BETTER GROUND THAN THE ONE JUST RETIRED. Read this before concluding the ratification reopened it.** It was filed as forbidden *by coverage*; **the boundary moved, so that objection is gone.** What replaces it is stronger, because it does not depend on any secrecy argument at all: **a manifest publishes the one field the checker does not decide on.** `check_anchors` ([scripts/asvs/scorecard.py:944](scripts/asvs/scorecard.py:944)) says so itself -- *"assert its token still resolves, and resolves UNAMBIGUOUSLY... A token that is GONE or AMBIGUOUS reds the gate... A token that is unique and present but sits at a different line is an **ADVISORY**"*. **The verdict turns on token presence and uniqueness. The line number is advisory and nothing branches on it.** +> **So publishing file-and-line pairs pays a real disclosure cost for a field that carries no decision weight -- and it is also the field most likely to be WRONG:** one run of this verifier carried **1181 advisory DRIFT rows**, anchors resolving at a line other than the recorded one. **A manifest would be simultaneously the most leaky column and the most stale one.** Reject it on that, not on coverage. > **THIS DOES NOT AUTHORISE A BUILD. Two conditions bind, and the first is a hard precondition:** > ``` > 1. CLEAR THE BROKEN ANCHORS FIRST. Making a check required while failures From 5b4cade90dddbdef50253147b27b5c78520cf5e6 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 23 Aug 2026 14:09:09 -0500 Subject: [PATCH 03/10] backlog: #1244 cited a control that cannot fail, in two places at once The row cited `git ls-files docs/security returns 0` as evidence the engine carries no scorecard data. The CONCLUSION is right and the INSTRUMENT is inert: `.gitignore:170` is `/docs/security/`, and `git check-ignore -v` names that rule for `docs/security/asvs-scorecard.toml`. The command returns 0 whether the scorecard is absent, present-but-ignored, or sitting in that directory right now. It cannot return a different answer, so it distinguishes nothing. Two further scope gaps in the same citation: it interrogates ONE PATH while the sentence claims something about the whole engine, and it counts FILES while "scorecard data" is a claim about CONTENT. Replaced with two controls that can each come back the other way: zero of the 29 tracked .toml files carry a [[cell]] header scorecard.py:2384 declares --scorecard required=True, no bundled default The [[cell]] sweep was run against a synthetic file that HAS one and fired, so the zero means absent rather than broken. IT WAS IN TWO PLACES -- the ranked table at :299 and the item body at :11822 -- and an assertion expecting one occurrence is the only reason both were found. Fixing the item alone would have left the table asserting the retired evidence, which is how a duplicated load-bearing fact drifts (SDS-3.5). The table keeps the short form and its pipe count is unchanged. Also narrows "zero scorecard data" to "zero scorecard CELLS". The unnarrowed phrase reads as a contradiction against this same row's finding that aggregate verdict tallies are already public in tracked engine files, which they are: scripts/docs/asvs_tally_baseline.txt carries them and is not a leak, because the owner ratified aggregate coverage as public and it holds no id-to-verdict pairing. Not a re-score. The author left that to whoever next scores the item and it stays open. Their two commits are unchanged; this correction is mine, not theirs. Surfaced by a five-lens disclosure screen of the row -- 14 findings raised, 0 sustained -- whose fact-check pass caught the citation the lenses were not looking at. Co-Authored-By: Claude Opus 5 --- docs/BACKLOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 9dbc3e24..e0daa3f8 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -296,7 +296,7 @@ Ordered by value descending, then difficulty ascending (cheapest first at equal | 67 | **#1134** | research an honest pass for ASVS 6.2.4 -- a top-3000 corpus that survives a 15-character minimum | 6 | 5 | _quick win_ | P2 | Still short at HEAD: password_min_length is 15 (messagefoundry/config/settings.py:1811, the item's :1757 anchor has drifted), the length clause is the first check in AuthPolicy.violations (auth/policy.py:112), password_check_breached ships True (:1818) and password_breach_corpus_file ships unset (:1826), so the bundled corpus supplies far fewer than 3000 policy-matching entries. Value 6 rather than 7 because a shipped operator-side widening path exists (_operator_corpus at auth/policy.py:65 loads a plaintext or hashed offline list), undocumented and awkward but real, and the item's own severity is modest on a first deployment. | | 68 | **#1144** | research an honest pass for ASVS 6.8.4 -- IdP-asserted strength and recentness when two of the three login legs assert nothing | 6 | 5 | _quick win_ | P2 | Gap stands on both axes: auth_time appears nowhere in the auth tree, flow.py requests no max_age, and service.py:884/:914 still mint mfa_verified=True on the directory legs with no IdP evidence, so a directory session would satisfy the step-up gate on a first deployment (value 6). The remainder prices at 5, not 6: the AD arm is deferred to #296 by the item's own text, so the deliverable is a research finding plus at most an OIDC-leg recency check, a setting and a SECURITY.md fallback, with the store limb bounded to one nullable column on a sessions table that already gained reauth_at the same way (store.py:3181). | | 69 | **#1199** | research an honest pass for ASVS 16.4.3 -- an engine that ships no collector against a verb that requires transmission | 6 | 5 | _quick win_ | P2 | Shipped defaults are unchanged: forward_host is None at settings.py:1413, the handler installs only behind if forward is not None at logging_setup.py:438, and the protocol default at :1415 is UDP. Value 6 not 7: the engine ships the entire secure-transmission mechanism (SyslogForward at logging_setup.py:240 with native TLS at :300, CA anchoring and hostname verification, plus the REFUSE gate at settings.py:2475), so an operator reaches a conforming posture by setting a host and forward_protocol=tls -- awkward, but a real workaround, which is what rung 7 requires to be absent. Difficulty 5 for measuring the REFUSE gate's actual coverage, arguing the deployed-system-versus-product reading against the text, and weighing a TLS protocol default against a precedent that a runbook edit bought and lost this cell in two days. | -| 70 | **#1244** | an engine change that breaks a vault ASVS anchor produces no state change and no attribution: the signal is real, late, and lands in a gate that is already red | 6 | 5 | _quick win_ | P2 | No engine-side pre-merge anchor check exists: the engine carries scripts/asvs/scorecard.py and zero scorecard data (git ls-files docs/security returns 0), no asvs verify context appears anywhere in .github/required-contexts.txt (141 lines, one ASVS entry and it is the crypto-inventory gate at :79), and the two engine ASVS workflows do other jobs. All three limbs stand. Difficulty 5 because the design must hand a public repository enough to name the engine file and line while naming no cell id, and the blocking limb inherits the precondition of clearing the standing failures first. | +| 70 | **#1244** | an engine change that breaks a vault ASVS anchor produces no state change and no attribution: the signal is real, late, and lands in a gate that is already red | 6 | 5 | _quick win_ | P2 | No engine-side pre-merge anchor check exists: the engine carries scripts/asvs/scorecard.py and zero scorecard CELLS (zero of the 29 tracked .toml files carry a [[cell]] header; scorecard.py:2384 declares --scorecard required=True with no bundled default. The earlier citation here, git ls-files docs/security returns 0, was inert: .gitignore:170 is /docs/security/, so it returns 0 either way), no asvs verify context appears anywhere in .github/required-contexts.txt (141 lines, one ASVS entry and it is the crypto-inventory gate at :79), and the two engine ASVS workflows do other jobs. All three limbs stand. Difficulty 5 because the design must hand a public repository enough to name the engine file and line while naming no cell id, and the blocking limb inherits the precondition of clearing the standing failures first. | | 71 | **#1270** | a claim that returns empty parks the lane at TERMINAL IDLE with work pending, and both routes there are silent | 6 | 5 | _quick win_ | P2 | Nothing names the REASON a lane stopped claiming: the 1222 route is DEBUG-only (sqlserver.py:7045-7056), the READPAST lane drop is silent (:792-800), and the only empty-claim signal carries woken-versus-not (stage_dispatcher.py:188-196). Written conditionally per section 0 -- a deploying site would see lanes recovered only by the sweep with no operator-visible cause on either server backend -- but the SYMPTOM is already observable, since queue_depth and backlog_seconds ship on the stats surface (api/app.py:1796-1802) and separate a stalled lane from a genuinely idle one. Difficulty is the reason threaded back across the store-to-dispatcher observer seam on two backends plus a sweep-disabled negative control. | | 72 | **#96** | Built-in "setup tester" — self-service capacity estimator that benchmarks the deployed setup and reports how much traffic it can handle (P2, adopter-facing) | 6 | 6 | _big bet_ | DEMAND-GATE | The reuse premise stays measured false: I enumerated every add_parser call in __main__.py and there is no capacity or setup-test subcommand, and knee appears in harness/ only inside TOML profile comments, so the knee-finder, the non-filling per-step gate, the /stats staleness precondition and the isolated-store guard are net-new across CLI, engine, store and metrics. Neither gating condition has occurred: no adopter has asked for a pre-cutover capacity check and the owner has not re-ratified the ADR 0074 sustain gate and estimand that the 14 blockers suspended. | | 73 | **#141** | TCP connection role selectable independently of direction (act-as-server vs act-as-client) | 6 | 6 | _big bet_ | DEMAND-GATE | Socket role is still hard-bound to direction at all three connectors (tcp.py:189 vs :430, mllp.py:858 vs :1407, x12.py:158 vs :491) and no role knob exists in config/models.py. Value 6 is rung 6: the socat/stunnel relay genuinely works but puts a hop outside the engine's count-and-log and its forward-secrecy floor. Difficulty 6 because the DestinationConnector contract at base.py:490-498 has no accept or bind hook at all, so a listening outbound is a new seam into the per-outbound delivery worker plus retry/backoff and the connection-status vocabulary. | @@ -11819,7 +11819,7 @@ _FHIR_ID_RE.fullmatch("abc\n") -> False the fix ## 1244. an engine change that breaks a vault ASVS anchor produces no state change and no attribution: the signal is real, late, and lands in a gate that is already red -> 🔢 **Re-scored 2026-08-20 -> P2.** Value **6/10** · Difficulty **5/10** · _quick win_. No engine-side pre-merge anchor check exists: the engine carries scripts/asvs/scorecard.py and zero scorecard data (git ls-files docs/security returns 0), no asvs verify context appears anywhere in .github/required-contexts.txt (141 lines, one ASVS entry and it is the crypto-inventory gate at :79), and the two engine ASVS workflows do other jobs. All three limbs stand. Difficulty 5 because the design must hand a public repository enough to name the engine file and line while naming no cell id, and the blocking limb inherits the precondition of clearing the standing failures first. _(was 6/10 · 4/10.)_ **NOT RE-SCORED, BUT HALF THIS RATIONALE IS GONE: the owner RETIRED the "name no cell id / no count" constraint on 2026-08-23 (see the ratified-boundary block below), so the leak-avoidance half of Difficulty 5 no longer applies. The precondition half stands and now binds explicitly. Gap 3 is separately CLOSED. A re-score is warranted and is deliberately left to whoever next scores this item, rather than smuggled into an amendment.** +> 🔢 **Re-scored 2026-08-20 -> P2.** Value **6/10** · Difficulty **5/10** · _quick win_. No engine-side pre-merge anchor check exists: the engine carries scripts/asvs/scorecard.py and zero scorecard CELLS -- zero of the 29 tracked `.toml` files carry a `[[cell]]` header, and `scripts/asvs/scorecard.py:2384` declares `--scorecard` `required=True` with no bundled default, so the verifier cannot run against engine-resident data. **The citation that stood here, `git ls-files docs/security returns 0`, was INERT, and that is recorded so nobody restores it: `.gitignore:170` is `/docs/security/`, so the command returns 0 whether the scorecard is absent, ignored, or sitting in that directory right now. A control that cannot return a different answer distinguishes nothing.**, no asvs verify context appears anywhere in .github/required-contexts.txt (141 lines, one ASVS entry and it is the crypto-inventory gate at :79), and the two engine ASVS workflows do other jobs. All three limbs stand. Difficulty 5 because the design must hand a public repository enough to name the engine file and line while naming no cell id, and the blocking limb inherits the precondition of clearing the standing failures first. _(was 6/10 · 4/10.)_ **NOT RE-SCORED, BUT HALF THIS RATIONALE IS GONE: the owner RETIRED the "name no cell id / no count" constraint on 2026-08-23 (see the ratified-boundary block below), so the leak-avoidance half of Difficulty 5 no longer applies. The precondition half stands and now binds explicitly. Gap 3 is separately CLOSED. A re-score is warranted and is deliberately left to whoever next scores this item, rather than smuggled into an amendment.** > > **Filed 2026-08-13. NOT "nothing detects this" -- that framing was wrong and is recorded below so nobody re-derives it.** The detection already exists and works; what is missing is that it cannot **block**, cannot **attribute**, and currently cannot **change colour**. > **THE COUPLING.** The scorecard lives in the vault (private); the code it cites lives in the engine (public). Every one of the **1,998** evidence anchors names an engine path, and **192** of them cite engine *prose* under `docs/` -- the kind of line an ordinary documentation edit rewrites without anyone thinking about security evidence. Nothing in the engine repo references the scorecard, and nothing can: **the engine tree contains the verifier (`scripts/asvs/scorecard.py`) but zero scorecard data files**, by deliberate design, because the data is what must stay private. From 8c20827b96b8819d7af4a93f07ed0e3e43e7e4e5 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 23 Aug 2026 14:42:05 -0500 Subject: [PATCH 04/10] backlog: name the mechanism in #1244's precedent warning, not the inverted letter The warning read "TODAY'S OWNER RULING DOES NOT COVER OPTION (a)". The owner ruling, read in full today, states that the letters are inverted between documents: .github/workflows/asvs-prove-absences.yml gives "option (a)" to the vault-token-in-public-CI design, while the ruling brief gives that letter to the anchor manifest. So a reader comparing the two documents took this warning to mean the opposite of what it says -- and it is a warning about what is NOT permitted, which is the worst place to invert. Now names the mechanism: publishing the anchor manifest, the enumeration. The ruling's own instruction is quoted inline so the next editor does not reintroduce a letter. No scope change. The item's conclusion, both binding conditions, and the unmeasured cardinality-correlation limb are untouched. Co-Authored-By: Claude Opus 5 --- docs/BACKLOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 09979817..5f944b25 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -11929,7 +11929,14 @@ required context **without first clearing the standing failures** *"converts a s permanently blocked repository."* **That ordering is not the builder's to clear either.** ***PRECEDENT WARNING 2026-08-23, from the seat that owns this vocabulary: TODAY'S OWNER RULING DOES NOT -COVER OPTION (a), AND MUST NOT BE CITED AS PERMISSION FOR IT.*** +COVER PUBLISHING THE ANCHOR MANIFEST -- THE ENUMERATION -- AND MUST NOT BE CITED AS PERMISSION FOR IT.*** + +***NAMED BY THE MECHANISM ON PURPOSE, corrected 2026-08-23 after reading the ruling in full.*** This +sentence previously said *"option (a)"*. **The letters are INVERTED between documents** -- +`.github/workflows/asvs-prove-absences.yml` calls the vault-token-in-public-CI design *"option (a)"*, +while the ruling brief gives that letter to the manifest. **A reader comparing the two takes this +warning to mean the opposite of what it says.** The ruling states the rule outright: *"STATE THE +MECHANISM, NEVER THE LETTER."* **The ruling sanctioned an individual pairing WHEN IT JUSTIFIES A DECISION** -- its whole basis is that a single citation in a design record or a tool comment **explains why something was built.** ***IT SAYS From 3f18051b2fd24e8f481c7d7101e9a196f9a92852 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 23 Aug 2026 15:22:59 -0500 Subject: [PATCH 05/10] backlog: file #1350-#1352, the three contested ASVS cells the coverage sweep never scanned The 2026-08-08 ledger-coverage sweep that produced the "research an honest pass for ASVS X.Y.Z" family scanned the partial and fail cells. It did not cover needs-review. So 186 cells carry an item and the three contested ones carry none -- two of them for about three weeks. That is a gap in the sweep's domain rather than a lapse: a needs-review cell has no agreed target to build toward, because two assessors disagree about what the requirement demands. Filing them as RESEARCH items fits that -- the deliverable is a settled reading, and "cannot honestly reach pass" stays a valid finding. #1350 ASVS 1.3.4 (L2) an image/svg+xml attachment is accepted, stored and served; the prior na said the engine neither accepts nor renders SVG, and the accepts half is false. A layered control IS shipped -- substring MIME classifier, sandbox CSP, forced attachment disposition, global nosniff -- so the open question is the classifier's completeness, not absence. #1352 ASVS 11.4.4 (L2) the prior na quoted the requirement with its second sentence truncated, dropping the clause that asks whether derivation PARAMETERS balance security against brute force. argon2id via argon2-cffi is present; whether its parameters were chosen, and whether the site inventory is complete, is not established. #1351 ASVS 10.4.13 (L3) the OIDC authorization request travels the browser-visible front channel by construction. The contest is whether the pinned text requires PAR (RFC 9126) or whether PKCE answers it. No factual claim in the derivation was refuted. Table and census: rows added to the CURRENT 2026-08-20 pass only. The 08-03 and 07-10 tables are frozen records of superseded passes and are byte-identical. The distribution lines are RECOMPUTED from the table rather than adjusted, per the file's own note that a stale census reads exactly like a current one: 228 -> 231 rows, V4 40->41, V5 58->60, P2 116->118, P3 65->66, fill-in 126->129. Numbers allocated via alloc.ps1, not chosen by inspection. Co-Authored-By: Claude Opus 5 --- docs/BACKLOG.md | 296 +++++++++++++++++++++++++++++------------------- 1 file changed, 178 insertions(+), 118 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 5f944b25..693d8bf5 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -156,7 +156,7 @@ superseded by the #75 browser ops dashboard). Sequencing context for the earlier ## Ranked backlog — value × difficulty on a ten-level scale (re-scored 2026-08-20) -> **What this pass is, and what it replaces.** Every one of the **228 open items** is re-scored here +> **What this pass is, and what it replaces.** Every one of the **228 open items** in that pass is re-scored here > against the code at `430590f6`. The 2026-08-03 table below is kept as the record of that pass and is > **superseded** by this one: it scored 102 items, and the corpus has more than doubled since. Its > distribution lines were computed over 109 open items and were never recomputed. @@ -216,11 +216,11 @@ superseded by the #75 browser ops dashboard). Sequencing context for the earlier > the banner wins. **Distribution.** Recomputed from the table below, not carried forward. -Value: **1**:1 · **2**:19 · **3**:21 · **4**:40 · **5**:58 · **6**:49 · **7**:28 · **8**:12. Difficulty: **1**:10 · **2**:45 · **3**:50 · **4**:44 · **5**:27 · **6**:40 · **7**:10 · **8**:1 · **9**:1. -Tiers: **P1** 18 · **P2** 116 · **P3** 65 · **DEMAND-GATE** 29. -Quadrants: _quick win_ 50 · _big bet_ 39 · _fill-in_ 126 · _money pit_ 13. +Value: **1**:1 · **2**:19 · **3**:21 · **4**:41 · **5**:60 · **6**:49 · **7**:28 · **8**:12. Difficulty: **1**:10 · **2**:45 · **3**:53 · **4**:44 · **5**:27 · **6**:40 · **7**:10 · **8**:1 · **9**:1. +Tiers: **P1** 18 · **P2** 118 · **P3** 66 · **DEMAND-GATE** 29. +Quadrants: _quick win_ 50 · _big bet_ 39 · _fill-in_ 129 · _money pit_ 13. -*All four lines sum to 228, the open-item count. They are recomputed with the table, +*All four lines sum to 231, the open-item count. They are recomputed with the table, never carried forward -- a stale census reads exactly like a current one.* Ordered by value descending, then difficulty ascending (cheapest first at equal value). @@ -342,119 +342,122 @@ Ordered by value descending, then difficulty ascending (cheapest first at equal | 113 | **#1277** | record an audit entry for every authorization grant by default | 5 | 3 | _fill-in_ | P2 | The defaults are unchanged and the item's counter-measurement holds -- exactly two grant sites (api/security.py:249, :773), so the named console flooder is not connected to the switch. Worth-if-built is the parity band rather than higher because PHI access is already audited unconditionally and the residual is non-PHI read authorizations, for which a documented one-line config switch is a clean workaround; the cell is level 2, so the ASVS band does not lift it. Difficulty is above a bare default flip because landing it reverses an owner-confirmed decision recorded at ADR 0118:156 and :235, rewrites the loosening classification at settings.py:4295-4296, and updates at least 5 test files and 7 documents. | | 114 | **#1296** | backlog-hygiene's claim extraction reads a backticked MENTION of BACKLOG #N as a CLAIM, so a PR explaining the gate is enforced by it | 5 | 3 | _fill-in_ | P2 | Unchanged at HEAD: line 66 pipes the PR title and body through a raw grep for the claim token with no inline-code-span blanking, so a backticked mention is indistinguishable from a claim, and the head -1 secondary defect is intact on the same line. A required status check that can red a compliant pull request is a false deny, the direction that gets acted on, and both obvious remedies damage the record. Difficulty 3 because the sibling precedent exists (Get-ScannableSegments blanking quoted spans) but its measured lesson forces a paired must-trip and must-not-trip arm, and the inline bash step has no testable seam today. | | 115 | **#1299** | unbacked_check reports commits whose CONTENT is fully landed as unbacked work, because a squash-merge and a local merge over a backed parent both leave an orphan commit object | 5 | 3 | _fill-in_ | P2 | Gap stands: unbacked_check.ps1:363 still reports the per-ref tally under the noun commits and :366-368 still prints the force-push remedy with no classification step, so the two routine mechanisms the item measured would alarm again and the printed command would act on the false alarm. Developer coordination tooling with no product, PHI or deployment axis, but the instrument that measures single-copy work loses credibility at a 7-of-7 rate (value 5); the remainder is a bounded change to an existing 369-line script adding a reachable-on-origin-main plus backed-parent classification, with paired must-trip and must-not-trip arms (difficulty 3). | -| 116 | **#81** | Alert escalation tiers + day/time thresholds + content (Action-Point) alerting | 5 | 4 | _fill-in_ | DEMAND-GATE | Both named remainder halves are absent at HEAD: content_match is only on the concrete notifier (alert_sinks.py:734) and appears in neither the AlertSink Protocol nor LoggingAlertSink, and AlertInstanceInfo (api/models.py:275-296) carries no escalation_tier despite the store already reading it back (postgres.py:4292). Value 5 is the parity/breadth rung with the escalation and schedule two-thirds already shipped; difficulty 4 because the load-bearing piece is exporting an alert emitter a Handler can reach, which has to be reconciled with the re-run purity rule rather than added to an existing seam. | -| 117 | **#124** | Batch-export message bodies from a connection log to a file | 5 | 4 | _fill-in_ | P2 | A scripted operator can already export through the audited API, so the remainder is a console affordance rather than a missing capability, which caps value at parity-with-a-workaround. Difficulty stays 4 because the proxy route must re-apply the step-up and the per-row can_access_channel check that a direct handler call skips, and _auth.py:533 records that export_messages paces its own PHI budget so the route must NOT pass phi= or it double-charges. | -| 118 | **#236** | Test-this-step and test-up-to-step with pinned upstream values | 5 | 4 | _fill-in_ | P2 | None of the step-scoped run symbols exist in ide/src, so the feature is unstarted, but the substrate it needs is shipped: the traced dry-run, the line-containment fold and the sample picker are all present. Value is capped at parity with a clean workaround, since running the whole handler in the Test Bench already works; difficulty sits at 4 because the stop condition and state dump ride an existing path while the pin mechanism, the redaction reuse and the IDE surface cross the CLI-to-extension seam. | -| 119 | **#328** | `audit-verify` cannot detect a truncated audit tail | 5 | 4 | _fill-in_ | P2 | The operator-runnable half landed, so a compliance job can snapshot and compare an anchor, but the automatic startup check at pipeline/engine.py:860 remains truncation-blind because no [integrity] anchor key exists at config/settings.py:3285-3309. Value 5 because the CLI path is a workable substitute for the automatic one; difficulty 4 because the exact point-in-time seal has to be replaced by a seal-on-stop or monotonic-prefix comparator before the three-file plumb through settings, engine and create_managed_app is worth wiring, and a prefix comparator lands on all three store backends. | -| 120 | **#1030** | Non-cp1252 characters in source are gated one file at a time, so the class keeps recurring | 5 | 4 | _fill-in_ | P2 | The Python half landed with the controls this ledger asks for, and its own scope note leaves 48 .ps1 files against 39 .py on the per-file mechanism the item names as the defect. Value 5 because the surface is scripts/ rather than the engine and each recurrence costs a manual audit; difficulty 4 because the reusable part is the walk and not the exemption model -- the shipped gate recognises a hardened file by matching stdout.reconfigure(, which has no PowerShell analogue -- and the silently-failing decode direction is still unowned. | -| 121 | **#1071** | A UNC-spelled governed root is not de-aliased by rev-parse, so rule 3c allows a config write through it | 5 | 4 | _fill-in_ | P2 | The hole is open and the gate file records it: scripts/hooks/worktree_gate.ps1:1607-1614 states that an extended-length or UNC admin-share spelling normalises to //?/c:/... or //localhost/c$/..., matches no governed root, was verified live on this box, and that closing it means folding both prefixes into Get-ComparablePath, moving rules 3, 3b, 3c and 3d with it. Difficulty 4 because the change itself is a lexical fold inside a five-line function (:164-168) and the cost is the cross-rule test matrix plus a negative control, not a new mechanism. | -| 122 | **#1121** | research an honest pass for ASVS 3.4.6 -- frame-ancestors on every response without weakening the attachment sandbox | 5 | 4 | _fill-in_ | P2 | The scoping is unchanged: frame-ancestors ships only on non-static /ui responses and the attachment response is served default-src 'none'; sandbox, which takes no fallback for frame-ancestors. Value 5 because the item itself records that practical clickjacking exposure on a first deployment would be low, so what is bought is posture plus the small measured hole on the 413 and 500 paths that carry neither header; difficulty 4 because the research must find a construction carrying the directive on every response without weakening either policy that is currently correct, and a global header and a per-route header interact rather than compose. | -| 123 | **#1141** | research an honest pass for ASVS 6.4.5 -- a deadline and a reminder for a credential the engine cannot reach the holder of | 5 | 4 | _fill-in_ | P2 | The gap stands: the 72-hour default at settings.py:1851 is enforced at auth/service.py:786-798 and stated nowhere a holder or issuing administrator would see it, and settings.py:4264 separately records the same field as unreported on the loosening surface. Value 5 is rung 5 -- a real conformance and usability gap with a clean out-of-band workaround, since the administrator issuing the credential can state the deadline. Difficulty 4: attaching the deadline to the reset response, the console one-time page and the create-user hint is a feature across a seam with no store work, ahead of a bounded reading of whether a 72-hour credential needs an automated reminder at all. | -| 124 | **#1147** | research an honest pass for ASVS 7.4.3 -- offering session termination as part of the MFA-change ceremony rather than beside it | 5 | 4 | _fill-in_ | P2 | disable_mfa still offers and revokes nothing, and the post-disable redirect still lands on a page whose only relation to session termination is a link, so the option remains adjacent to the ceremony rather than part of it. The capability ships and is one click away, which caps value; difficulty 4 covers research plus a uniform ceremony across five factor-change paths without cutting the caller's own session mid-flow. | -| 125 | **#1158** | research an honest pass for ASVS 10.2.2 -- whether an id_token iss claim is a real substitute for the RFC 9207 iss parameter | 5 | 4 | _fill-in_ | P2 | The defense is still the id_token iss claim compared to the pinned issuer (oidc/claims.py:227) and the RFC 9207 authorization-response parameter is absent from all four files of the OIDC package, so the unverified equivalence the residual flags is unchanged. Severity is bounded because the back-channel legs pin their own token endpoint per connection, and the likely deliverable is a standards determination plus, if needed, one added parameter check on an existing seam. | -| 126 | **#1163** | research an honest pass for ASVS 11.1.2 -- how a cryptographic inventory can claim ALL without an unmeasured completeness claim | 5 | 4 | _fill-in_ | P2 | The four uninventoried surfaces are inventoried and the unmeasured completeness claim has been replaced with at-least language, so the residual is the missing per-row usage sentences (13 of 65 section-4 table lines carry one) plus the research question about deriving the inventory mechanically. That research is cheaper than filed because crypto_inventory_check.py already walks five stated first-party roots and fails the build on undocumented usage, so a stated-corpus instrument partly exists. | -| 127 | **#1192** | research an honest pass for ASVS 15.2.3 -- getting development subcommands out of the wheel without taking dryrun off the operator's box | 5 | 4 | _fill-in_ | P2 | Clause (a) still binds: messagefoundry/__main__.py registers dryrun (:162), adr-analyze (:225), generate (:350), lens (:375) and import corepoint (:442, :449) as unconditional subparsers on the same entrypoint as serve (:63), while clause (b) stays clean via the sdist only-include at pyproject.toml:21. Value 5 on the item's own severity, low product effect and no added network surface, which makes this shipped-artifact hygiene; difficulty 4 for a research pass that must settle whether an entrypoint gate reads as does-not-include, plus CLI tests if it becomes a build. | -| 128 | **#1256** | the federated binding guards account-continuity but never subject-exclusivity, so two accounts can bind one identity | 5 | 4 | _fill-in_ | P2 | The behavioural half is closed in shipped code -- auth/service.py:1219-1232 refuses a second account binding one (issuer, subject), with the lookup on all three backends and a sequential test at tests/test_auth_oidc_service.py:456 -- so the remainder is only the database constraint that makes the read-then-write at :1219/:1232 atomic, which is worth a 5 as auth hardening with the app guard covering every non-concurrent case. Difficulty drops to 4 because the amendment moved the design half (refuse rather than re-point) into shipped code and comment, leaving a well-precedented seam: all three backends already declare unique indexes (ux_webauthn_label, ux_search_presets_owner_name), so the novel cost is one first-of-kind SQL Server ALTER COLUMN off NVARCHAR(MAX) (store/sqlserver.py:1358; the file contains zero ALTER COLUMN today), a filtered index for SQL Server NULL semantics, and a CI-only concurrent-bind test on the two server backends. | -| 129 | **#1264** | The seat clock fires on cadence but its fanout skips seats, and the tick's own rubric sends every seat to look at the clock | 5 | 4 | _fill-in_ | P2 | Fleet tooling with no deployment axis, but silent under-delivery of wake-ups is self-concealing and cost seats a day of divided investigation, so worth is mid-range. Difficulty 4 because the remainder is undiminished: read the roster selection source, instrument send-side per-firing results, and re-measure, which is the only thing that separates a roster fault from a send fault, and it must be done against files that are not tracked in this repository. | -| 130 | **#1266** | the seat clock's broadcast fanout drops seats per-firing, and no seat can detect it from its own inbox | 5 | 4 | _fill-in_ | DEMAND-GATE | The item's stated first deliverable is untouched: seat-tick.ps1 is outside version control anywhere in the tree (zero hits from git ls-files against 1997 tracked files, and the only repo reference is docs/BACKLOG.md itself), so every downstream fix would still be an unattributable write to a live machine-global control. Value 5 because this is internal coordination tooling with no product or PHI axis, where the one confirmed defect is the seats-unreadable path that drops every seat for a firing and exits 0; difficulty 4 because the concrete fixes are individually small (ConvertFrom-Json -AsHashtable, emitting SUPPRESSED(opt-out) instead of a bare continue, a durable per-firing decision line carrying a rotation answer) but sit behind the version-control precondition and a 2000-character tick-body cap that kills the fleet clock silently. | -| 131 | **#1269** | the seat clock has no external alarm, and the obvious implementation reads healthy at the moment it should fire | 5 | 4 | _fill-in_ | P2 | The serial re-arm chain has no independent observer inside anything this repository ships, so its death stays silent by construction. Value is moderate because this is fleet tooling with no product axis and peer messaging partly substitutes; difficulty reflects that the naive implementation is wrong in two measured ways, so the build must carry tick-identity dedupe, the suppression exclusion, a pinned absolute path, the throttle-age consistency check, and the discriminating test that fires on a fresh file missing the watched seat. | -| 132 | **#1072** | Rule 3c reads TEXT, and these text shapes are still not read: backtick substitution, variable targets, newline and subshell cd, a heredoc body | 5 | 5 | _fill-in_ | P2 | Two fail-opens on a shared disarm-write rule plus two over-denies, all four reproducible from the shipped source, on a maintainer guardrail rather than a product control. Difficulty 5 because the target-token half must be shell-aware per tool payload rather than one regex, with red-first tests for each shape. | -| 133 | **#1110** | research an honest pass for ASVS 2.2.3 -- whether cross-field reasonableness is shippable at all in a code-first engine, or belongs to the feed author | 5 | 5 | _fill-in_ | P2 | The gap is unchanged: consistency.py:11-17 still describes itself as a compose-it-yourself toolkit, its only non-test importer is samples/consistency/validated_adt.py:27, and nothing on the shipped message path calls it. The research question is genuinely open and its most likely output is a negative finding, so worth-if-built is bounded by the clean existing workaround of a Handler composing the primitives; difficulty carries the HL7-general rule-set research plus, if a set exists, a check on the message hot path with tests. | -| 134 | **#1112** | research an honest pass for ASVS 2.3.4 -- a quota that holds across concurrent uploads and across engine shards at once | 5 | 5 | _fill-in_ | P2 | Only the cross-shard limb remains and it is small: settings.py:461-466 records in shipped code that shards over one uploads_dir enforce one budget, so the remainder is an at-most N-1 file overshoot on a subsystem that is OFF on shipped defaults (uploads_dir default None, settings.py:442) and whose cell the owner already ruled holds at partial. Difficulty is the store-row-versus-advisory-lock decision plus a demonstrated two-shard concurrent-writer run. | -| 135 | **#1150** | research an honest pass for ASVS 7.6.1 -- bounding time since the IdP authentication event without forcing a credential prompt every round trip | 5 | 5 | _fill-in_ | P2 | With federation enabled a first deployment could not bound time since the IdP authentication event at all, and the one shipped lever destroys single sign-on, so there is no acceptable workaround. Difficulty 5: an owner ruling plus one setting, a max_age parameter on the existing authorize call and an auth_time comparison at the id_token seam, with a fallback for IdPs that omit the claim. | -| 136 | **#1151** | research an honest pass for ASVS 8.1.1 -- documenting the data-specific access rules where a reader can actually find them | 5 | 5 | _fill-in_ | P2 | The code enforces more than the documentation describes, so there is no product effect and the residual is silent drift rather than exposure, which caps this below the surfaces that would misbehave on a first deployment. Difficulty 5 holds because the deliverable is not prose: the item requires establishing whether a drift gate over data-scoping claims is constructible at all, plus the public-versus-vaulted split the dangling docs/security pointer is a symptom of. | -| 137 | **#1165** | research an honest pass for ASVS 11.2.2 -- crypto agility for the at-rest and integrity core without shipping a downgrade surface | 5 | 5 | _fill-in_ | P2 | A deploying site could not change the at-rest AEAD, audit MAC, KDF or argon2 parameters without a code release, but the additive-registration path is already built and ruled, so the workaround is a release rather than a redesign. Difficulty 5: research plus an ADR recording the ruling, with no migration and no store-backend leg implied. | -| 138 | **#1227** | Console resend POST bypasses the engine's step-up requirement across the CoreHandlers seam | 5 | 5 | _fill-in_ | P2 | The mismatch is byte-present at HEAD, with the console's own comment at :55 recording the no-step-up property as a fact rather than closing it. Difficulty stays 5 because the obstacle is structural -- a body-carrying POST cannot survive the re-auth redirect -- so this needs a chosen shape (stash-and-replay, a body-less confirm step, or a documented bounded exception), not a dependency swap. | -| 139 | **#1280** | ship the threat model, the handler shared-responsibility note and the dependency-handling note with the source | 5 | 5 | _fill-in_ | P2 | Gap stands: none of the three documents is tracked under docs/ at HEAD, and the drift suite still announces itself inert through ThreatModelDocUnenforced (:326, :346), so publishing turns on an existing 34-function gate at no test-writing cost (value 5, an adopter-facing artifact with no deployment axis). Difficulty 5 because the redaction is judgement work under a subtraction rule, not a copy: the item measures 20 plus 21, 35 plus 3, and 1 plus 1 flagged lines across the three files, each published text needs owner sign-off, and the landed text must then satisfy the suite's pinned headings, anchor registries and numeric-parity assertions. | -| 140 | **#78** | Custom message-definition data model + conformance validator; NCPDP codec | 5 | 6 | _money pit_ | DEMAND-GATE | The whole scope is still remainder: NCPDP appears nowhere under messagefoundry/ and the validator's profile argument is still an accepted no-op (parsing/validate.py:48-56). Value 5 because a code-first Handler is a clean workaround and the 75-feed estate port completed on one; difficulty 6 because the remainder after the #1213 codec split is a persisted definition model the code reads plus a report-only validator, spanning parsing, config and the store it is read from, while staying on the correct side of the #26 no-GUI-modeler line. | -| 141 | **#85** | Cloud object-store + generic message-bus destinations | 5 | 6 | _money pit_ | DEMAND-GATE | Re-measured: the transports package carries 25 modules and none is an object-store or message-bus driver, and pyproject.toml names none of boto3, azure, google-cloud, kafka or pika. The connector registry gives an adopter a clean code-first workaround so value stays at parity breadth, and the remainder is the whole scope (four-plus drivers plus four vetted dependencies through the hash-locked lock file, each with credential sourcing and egress allow-listing), which is past the single-connector band 5. | -| 142 | **#99** | AD/gMSA production-deployment hardening — turnkey enterprise (Windows/AD) install (P3, on-trigger) | 5 | 6 | _money pit_ | DEMAND-GATE | The engineering half is on main and the sole residual is provisioning, not code, so the price is the rig rather than the patch. Difficulty 6 rather than the banner's 3 because the banner's 3 measured the turnkey polish that has since shipped, while the remainder needs a domain lab the project does not own. | -| 143 | **#1003** | Validate the lab and discharge the four hardware-gated residuals | 5 | 6 | _money pit_ | DEMAND-GATE | The owner ruled 2026-08-20 that no multi-VM lab exists, so the trigger cannot fire, and the item's own cheapest deliverable is undone: docs/BACKLOG.md:3075 still tells a reader #351's blocker is scheduled to become false. Worth-if-built is breadth over four validation residuals whose own banners read 5, 3, 3 and 4, none of them a shipping-default defect, so 5 rather than a number above every part. Difficulty 6 on the explicit Windows-CI-gated anchor: #320 needs a registered self-hosted WS2025 runner, #99 and #98 a real DC plus AD CS plus gMSA, #351 a real SQL Server. | -| 144 | **#1113** | research an honest pass for ASVS 2.3.5 -- which flows in a PHI engine are high-value enough to demand a second approver, under a default that must not strand a single-operator site | 5 | 6 | _money pit_ | P2 | The code matches the item's substance: the gate ships off, three operations are registered and all three are guarded at api/app.py:2236, :2730 and :2837, and the default approvable set names only two, so no user-administration and no PHI-export flow is approvable in any configuration. Value stays mid-band because the consequence is a narrow opt-in control gap with no first-deployment product effect; difficulty is 6 because an honest pass most likely means widening the registry across API, settings, audit and console while resolving the self-approval availability cost at api/approvals.py:120. | -| 145 | **#1155** | research an honest pass for ASVS 10.1.1 under the owner ruling that a default-off OIDC feature is a partial | 5 | 6 | _money pit_ | P2 | No product effect and the enablement boundary is an owner ruling rather than a defect, but the same question governs at least 10.1.2, 10.2.1, 10.2.2, 10.5.1 and 10.5.4, so answering it once carries more than one cell and holds value at 5. Difficulty stays 6 because the two custody deltas the residual names, an operator-declared cleartext relaxation under ADR 0153 and the ECH sidecar's loopback bearer hop under ADR 0139, are cross-cutting rather than record work, and the honest outcome may still be that no pass exists. | -| 146 | **#1186** | research an honest pass for ASVS 14.2.4 -- doc-to-code fidelity when the measuring document is written as a mirror of the code | 5 | 6 | _money pit_ | P2 | The operator-surprise evidence that carried this item's severity is repaired -- all four named documents now describe the shipped auto-bound and record the previous inversion (CONFIGURATION.md:1529, SECURITY-LOOSENING.md:221-223 and :300, PHI.md:1061-1063) -- so the remainder is the research question plus a binding gate for gate-behaviour and integrity prose, which the item itself doubts is mechanisable. Difficulty stays at 6 for exactly that reason. | -| 147 | **#1195** | research an honest pass for ASVS 15.4.4 -- fair thread access on the shipped SQLite posture, or a measured argument that the existing pools already suffice | 5 | 7 | _money pit_ | P2 | The only cross-subsystem reservation still ships off and is still SQL-Server-only by construction, so on the shipped SQLite posture route and transform CPU and all blocking transport I/O share one default pool with the argon2 semaphore as the sole reservation. Value 5 because connector timeouts bound the wait and the recorded assessor dissent may be right that bounded pools already satisfy the verb, so the practical exposure is a delay rather than starvation; difficulty 7 because any mechanism has to clear ADR 0071's measured NO-GO boundary without regressing throughput, and the operative phrase about a reasonable timeframe is recorded as unassessed, so even the argument route needs a new measurement. | -| 148 | **#1215** | ADR 0161 and `mail-drain.ps1` describe the pre-wiring channel, and the script contradicts itself about markers | 4 | 1 | _fill-in_ | P3 | The inverted marker model is rewritten, leaving two false status claims and one glyph. Value stays 4 because the remainder is the same trap class rather than a milder one: ADR 0161's Status line and its Status section agree with mail-drain.ps1's header that nothing is wired, so a reader who re-reads either document has the error confirmed, and only reading install-coordination.ps1:278-279 or the ADR's own contradicting checklist at :405-408 falsifies it. Difficulty 1 because the remainder is edits to two documentation surfaces with no code and no test. | -| 149 | **#167** | Test Bench metadata seeding | 4 | 2 | _fill-in_ | DEMAND-GATE | The trigger has two clauses and only one is satisfied: SetMeta ships at wiring.py:2866 but no read side exists anywhere -- zero meta_get hits across messagefoundry/ and ide/src/ and no meta accessor on Message -- so a transform that READS metadata cannot be written, and the Test Bench cannot need to exercise one. Worth-if-built is IDE Test Bench DX and the seam is a --meta parameter on route_message (dryrun.py:524) and dry_run (:714) plus the Test Bench CLI channel. | -| 150 | **#171** | Runtime log-verbosity control + in-product log viewer | 4 | 2 | _fill-in_ | DEMAND-GATE | Remainder is console wiring over a JSON API that already answers the live-incident case, so an operator has a working path and the worth is console polish. Difficulty 2: a page builder emitting attributes the JS already registers, two /ui routes on the existing console seam, and a golden-surface update. | -| 151 | **#177** | Effective-permission inspector for a user | 4 | 2 | _fill-in_ | P3 | Remainder is one console pane plus an apiclient wrapper over an endpoint that already resolves the flattened set, so the workaround is a direct API call and the worth is console polish on an RBAC-audit surface. Difficulty 2: an additive pane on the existing user-detail seam plus a golden-route update. | -| 152 | **#1026** | The ASVS 12.1.1 TLS-floor probe silently does not run with the console off, and its own comment names three of its four conditions | 4 | 2 | _fill-in_ | P3 | I attacked the shipped claim on four fronts and it survived the first three. (a) The gate itself: messagefoundry/__main__.py:2130-2145 now refuses on `tls_terminated_upstream and data_class is DataClass.PHI and enforcing and not settings.api.public_origin` -- owner ruling (a), console-independent, sitting immediately above the probe gate at :2157-2161. (b) The comment limb: :2147-2156 now states the fourth condition explicitly and says why it is belt-and-braces rather than a hidden scope narrowing, so the "names three of its four conditions" complaint is discharged. (c) Test coverage that actually covers THIS: tests/test_api_tls.py:1437-1470 has the console-OFF arm, a console-ON arm, and a synthetic-data negative control; I ran them under .venv and got 3 passed. The console-OFF assertion is discriminating rather than coincidental -- it asserts "12.1.1" in stderr, and `grep -n "12\.1\.1" messagefoundry/__main__.py` returns 5 hits of which the only reachable one with `public_origin` unset is the new refusal at :2139 (the probe block at :2157 requires a truthy public_origin to enter). (d) A second inert copy of the gate: `grep -rn probe_tls_floor` over non-test Python returns exactly one call site, :2165, so there is no stale duplicate ladder. Where it does NOT hold up is actionability and documentation. The refusal message this fix added instructs the operator to set `[api].public_origin`, which settings.py:4096-4099 relocates and refuses at load -- the fix hands an operator a remediation that fails. And the landing commit changed no documentation while its own test comment flags the runbook as needing an update, leaving the published config docs describing the setting as console-scoped when it is no longer. That is one limb shipped and a second not, which under this pass's own rule is partly_shipped, not confirmed_shipped. Value 4 on the remainder: the ASVS 12.1.1 control is genuinely no longer inert, so the security gap is closed; what is left is an unactionable string in a hard refusal plus a doc line -- DX-grade, with a clean workaround (CONFIGURATION.md:188 records the relocation). Difficulty 2: a string edit on an existing seam, one assertion, one doc paragraph, no store or backend work. | -| 153 | **#1038** | Rule 3b's remediation names `new.ps1` siblings while most live worktrees are harness-created and nested | 4 | 2 | _fill-in_ | P3 | A developer-guardrail wording question with no product, engine or PHI effect: the printed command works, it is just not the mechanism that made the reader's own worktree. Difficulty 2 because the code half is one hint string feeding the existing emitters plus a test, with the owner decision being the slow part rather than the edit. | -| 154 | **#1039** | `git worktree add --force` also defeats the already-checked-out guard, so "git will refuse this" must be written as conditional | 4 | 2 | _fill-in_ | P3 | The gate's own site is already written as conditional (worktree_gate.ps1:865-879 spells out the three bypass flags and the allowlist reason), so the remainder is one measurement plus the sweep for other deferring sites, and at least two unconditional ones survive (prune-merged.ps1:1271, BACKLOG-CLOSED.md:6675). It ships nothing runnable to an adopter, which caps it at internal-tooling hygiene, and the work is one command plus doc edits. | -| 155 | **#1119** | research an honest pass for ASVS 3.4.1 -- HSTS on all responses from an engine whose shipped bind is plaintext loopback | 4 | 2 | _fill-in_ | P3 | The remainder lands no code: it is a scoping ruling on the honest unit of assessment plus at most a documentation and scorecard change, on an L1 cell with no PHI effect. Difficulty 2 because the coverage construction and the single shared hsts_applies definition already exist. | -| 156 | **#1135** | research an honest pass for ASVS 6.2.11 -- whether publishing the enforced context words is durable enough to count | 4 | 2 | _fill-in_ | P3 | The list a reader needs is published, so the remaining worth is the decay guard the item itself calls the load-bearing half plus the 6.2.11 pointer decision alongside 6.1.2. Difficulty 2: one small test asserting the doc list equals CONTEXT_WORDS, plus a docs pointer edit. | -| 157 | **#1217** | `retry_max_attempts` has no `>=1` floor, and retry-forever has no TOML or env spelling | 4 | 2 | _fill-in_ | P3 | Half 1 ships with its reasoning in the code at settings.py:1078-1086 while models.py:279 stays deliberately unfloored, so the remainder is half 2 only: TOML has no null literal and no validator accepts a word, so the documented retry-forever posture cannot be expressed from connections.toml or the environment at all. Value 4 because a code-first Python connection expresses it today, a clean route on a code-first project; difficulty 2 for a field validator coercing a string spelling to None plus the docs/CONFIGURATION.md:782 delivery catalog row, which also still asserts the pre-floor behaviour and is now false. | -| 158 | **#1235** | a citation to an unallocated backlog number is a trap that arms itself the day the number is issued | 4 | 2 | _fill-in_ | P3 | The rule and both coverage residuals landed, leaving one gap: the detector runs only inside pytest, which is skipped on documentation-only pull requests, and a citation is introduced by editing prose. Wiring it as a workflow step with a paired must-trip and must-not-trip arm is a small additive change on an existing gate seam, and the coverage bound stands regardless since the detector cannot see the private companion repository where the filed instances live. | -| 159 | **#1245** | an administrator password reset re-arms bootstrap retirement, permanently disabling the account it was meant to recover | 4 | 2 | _fill-in_ | P3 | Attacked the shipped claim by re-walking the item's own chain at HEAD rather than trusting names. The gate at auth/service.py no longer tests must_change_password at all: _unclaimed_bootstrap (:598-633) returns None when password_claimed_at is not None, and it is the SINGLE predicate called by both readers -- _retire_superseded_bootstrap (:642) and bootstrap_expiry_warning (:679) -- so the second open-coded copy the item warned about is gone too. Enumerated every must_change_password reference in service.py (13 sites); none is a claimed-ness test. The column is real, not decorative: password_claim_set (store/store.py:1230-1259) emits COALESCE(password_claimed_at, ?) only when must_change_password is False, and an EMPTY term otherwise, so admin_reset_password (:2867, still setting the flag True per ADR 0164) can neither stamp nor clear it. Present on SQLite, Postgres and SQL Server with in-place upgrade backfills. The confirming experiment the item said "does not exist today" now exists and is written in the correct direction -- test_admin_reset_does_not_re_arm_retirement_of_a_claimed_bootstrap asserts the temp credential logs in after a reset on a two-administrator system -- plus sibling tests for the restart and create_local_user triggers, the write-once stamp, the backfill, and the directory-admin regression the fix could have introduced. Docstring and SECURITY.md:61 both corrected. So the availability defect itself is closed; what stops this being confirmed_shipped is the diagnostic limb, which the item states as a separate deliverable and pairs with an explicit "DO NOT CLOSE THIS ON A GREEN RE-RUN". Remainder valued as test instrumentation guarding an ASVS 6.4.6 property (a reset temp that fails to force rotation would be a real defect) but shipping nothing an operator runs; difficulty 2 -- an additive read on an existing seam in one test, no backend work. | -| 160 | **#1249** | SECURITY.md documents an ingest-plane rate limit that no code-first or TOML configuration can reach | 4 | 2 | _fill-in_ | P3 | I attacked the shipped claim limb by limb rather than trusting the lander's amendment. Limbs that DID ship, verified: both keys are keyword-only parameters of `MLLP()` (`config/wiring.py:1028-1029`) and land in the spec settings (`:1155-1156`); the TOML surface reaches them through the same `factory(**settings)` (`config/connections_file.py:286`), tested at `tests/test_mllp_message_pacing.py:199-208` with an unknown-key positive control at `:213-218` that discriminates a `**kwargs` swallow; the off default is preserved and tested (`:188-197`, `transports/mllp.py:116`); the `SECURITY.md` row was reworded rather than left saying "off" as a bare state (`docs/SECURITY.md:1728` now states both surfaces express the keys and why off is ruled), and the doc-to-code guard reads reachability from `inspect.signature(wiring.MLLP)` rather than pinning a preference (`tests/test_security_doc_rate_limits.py:535-585`); `docs/CONNECTIONS.md:289-290` documents both keys. So this is not an anchor-moved false negative -- the primary limb genuinely landed. What refutes "fully shipped" is a surface the item never checked and the fix itself created. The VS Code connection form is not a hand-maintained mirror; it renders whatever `connection schema --json` introspects (`ide/src/connectionSchema.ts` header comment), and section headings come from the own-line comment block preceding a parameter. I executed `build_schema()` in the repo venv and read the emitted `section` for `max_messages_per_second`: the full six-line rationale block, carrying an internal backlog number and the now-false clause that no factory parameter or connections.toml key can populate the setting. That is the same defect class the item was filed over -- an operator-facing statement about the control's reachability that contradicts the code -- relocated to the GUI, which is precisely what the item's "whichever is chosen, the other must not be done by halves" clause forbids. Two smaller residuals are in-repo and checkable: a test docstring still declaring the item open and the choice unmade, and the deferred ledger closure the amendment explicitly left to the ledger seats. Remainder value 4: DX and console-surface polish under the rubric, held at 4 rather than lower because the text an operator would read is false, not merely untidy. Difficulty 2: a comment restructure on an existing introspection seam plus a pinning test and the ledger edits, no runtime behaviour change. Not scored higher on the ten unpaced inbound modules, because the item's own fix menu is (a) expose or (b) delete the row, and pacing other transports was framed as evidence of width, not as the ask. | -| 161 | **#1262** | the docs-only CI short-circuit skips the gates whose subject is documentation | 4 | 2 | _fill-in_ | P3 | The measured defect, membership of the ungated doc-guards lane, is closed: both named absentees are in DOC_GUARDS at ci.yml:236-243, the non-member ruling is recorded inline at :231-235, and the step runs precisely on the docs-only pull requests that skip the suite (:201-202). Value 4 and difficulty 2 on the remainder, which is re-deriving the set in an environment carrying the five CI extras the trace lacked and demonstrating that a Markdown-only pull request carrying a deliberate violation actually goes red, since the lane has never been shown able to fail. | -| 162 | **#1273** | the module that exists to write the C0/DEL test once writes it twice, and logging_setup re-derives it a third time | 4 | 2 | _fill-in_ | P3 | One limb remains and it is a pure refactor: logging_setup.py:66-70 still derives its own table from range(0x20) plus 0x7F and does not import controlchars at all, while the module now states the alphabet once at :67 with both arms reading it. Value 4 because nothing is mis-screened today, two of the three named copies already agree, and at least two further independent derivations sit outside this item's scope (spreadsheet.py:37, soap.py:263), so folding logging_setup in buys the stated cross-module leverage for one file rather than repo-wide. Difficulty 2 for one import, one derivation with an explicit tab subtraction, a test pinning that tab stays excluded, and renaming the acceptance test's scrub_control_chars, which returns zero code hits. | -| 163 | **#1298** | The archive dialog's permanently-discarded warning is not a loss test: a worktree behind main sees landed files as untracked | 4 | 2 | _fill-in_ | P3 | Neither half exists at HEAD: docs/WORKTREES.md carries no subsection stating the warning is an index test rather than a loss test, and scripts/coord/ holds no helper that answers the recoverability question. Value 4 because the cost is developer coordination only, but it is a high-frequency prompt that is wrong in the common case and trains the dismissal habit; difficulty 2 because the check is three git commands over a dirty set and the helper is a small additive script beside the existing coord tooling, with the one real design constraint being that anything it cannot read must count as unrecoverable rather than clean. | -| 164 | **#234** | Steps view projection refreshes on save only | 4 | 3 | _fill-in_ | P3 | Remainder is a UX latency fix on an authoring surface, which is the DX and console polish band, not a functional gap. The debounced channel and the deferral machinery already ship and the false stale-disk premise is already corrected in the code comment at :924-930, so the change is one subscription plus tests; the cost that keeps it above a trivial edit is the ADR 0076 section 5 amendment re-arguing a wholesale-adopted guardrail. | -| 165 | **#343** | Sandbox child stderr is inherited unframed into the engine log stream | 4 | 3 | _fill-in_ | P3 | Both named problems survive: attribution, because stderr=None (sandbox.py:446) leaves child lines indistinguishable from engine lines, and the print() PHI path, because the #1054 filter installed at _sandbox_worker.py:49 is a property of the logging handler only. Value 4 given the same-admin threat model and no product-facing surface; difficulty 3 because the fix is stderr=subprocess.PIPE plus a relay thread mirroring the existing stdout reader, plus a bootstrap redirect of the child's sys.stdout away from the frame fd. | -| 166 | **#351** | SQL Server failover test asserts on a 0.35s wall-clock margin across a real DB round-trip | 4 | 3 | _fill-in_ | DEMAND-GATE | The margin is unchanged in the shipped test and the item's own remedy is parked behind #1003, whose trigger the owner ruled on 2026-08-20 will not fire. Value 4 because the cost is a CI red that misreads as the PR's own defect, with no engine path and no PHI effect; difficulty 3 because the remainder is a one-file test change plus an _acquire latency benchmark that has to run against a real SQL Server rather than the mocked path. | -| 167 | **#1082** | Rule 3c's deny text for a `--global` or `--system` disarm write names the wrong mechanism, and whether the write takes effect is not knowable from the command | 4 | 3 | _fill-in_ | P3 | The wrong sentence is still the only one rule 3c emits, and the rule reaches it for a --global write because the only pre-deny exclusion at :981 is for reads, so a governed cwd resolves and denies with prose that names a mechanism the write does not use. The verdict must stay deny, so the work is a scope-aware wording plus a test that asserts the string rather than the verdict. | -| 168 | **#1116** | research an honest pass for ASVS 3.1.1 -- what the console's browser-support contract actually is, when the document that carried it cannot ship | 4 | 3 | _fill-in_ | P3 | The contract is still sited only in source at _security.py:51, docs/SYSTEM-REQUIREMENTS.md:135 asks for nothing more than a modern browser, and /docs/security/ is still blanket-ignored at .gitignore:170, so the document two earlier pass-moves cited still does not reach a deploying site; the vault scorecard confirms the cell has not moved. The remainder is a verified feature list plus a supported-browser statement in a shipped doc. | -| 169 | **#1123** | research an honest pass for ASVS 3.5.5 -- what a trustworthy postMessage origin check is inside a VS Code webview | 4 | 3 | _fill-in_ | P3 | No PHI and no engine surface, the interface exists only in the VS Code extension, and the platform-limits finding is already written down and test-enforced, which leaves value in the polish band. Difficulty 3 is the vault record plus the SDS-3.7 discharge, and that discharge is not a formality: webviewMessaging.ts claims every panel is served script-src nonce, while stepsView.ts:959 emits a CSP of default-src none plus style-src unsafe-inline with no script-src at all, so the blanket claim has to be checked panel by panel. | -| 170 | **#1124** | research an honest pass for ASVS 3.7.5 -- deriving a supported-browser matrix rather than authoring one that fits | 4 | 3 | _fill-in_ | P3 | SYSTEM-REQUIREMENTS.md:135 still offers only a modern browser, the web-console plan still carries the matrix as open question 1 at 11-web-console.md:446, and SECURITY-DOCS-POLICY.md:5 still withholds the operator-facing copy while the behavioural half ships in _security.py and _html.py, so the antecedent the requirement grades against remains undefined. Value 4 because this is documentation completeness over a working control set with no exposure and no product effect; difficulty 3 because the deliverable is a table derived from the shipped feature floors plus two owner rulings, touching no code, no mypy surface and no test. | -| 171 | **#1129** | research an honest pass for ASVS 5.2.3 -- a literal before-uncompressing check when the declared size is attacker-controlled | 4 | 3 | _fill-in_ | P3 | The permissive Handler-facing default the item re-scoped itself onto is gone: a caller must now pass max_output_bytes or an explicit None, so the severity paragraph no longer describes the code, and the file-count clause is met on the default. What remains is a method question, whether the pinned verb admits incremental per-member enforcement or wants a recorded deviation, with no code change indicated, which prices both numbers in the record-accuracy band. | -| 172 | **#1130** | research an honest pass for ASVS 5.3.2 -- containment for filenames a remote SFTP or FTP server chooses | 4 | 3 | _fill-in_ | P3 | The security-material half shipped: a hostile listing name is refused at the source (remotefile.py:955) before the pattern filter and before the join, so the traversal primitive the item priced is gone and its founding sentence is false in the code. Value 4 on the remainder, which is a census across the file-path-constructing transports plus the record work to move the cell; difficulty 3 because the guard and its both-polarity tests exist to copy and any further sites are the same shape. | -| 173 | **#1156** | research an honest pass for ASVS 10.1.2 when the flow-binding secrets are unconditional but the flow ships off | 4 | 3 | _fill-in_ | P3 | The enablement premise is unchanged (oidc_enabled ships False, now at settings.py:1942 rather than the cited :1880, and :2190-2210 refuses it without ad_enabled) and both live deltas the research must carry are present: the flow cookie's __Host- twin at _auth.py:783 and the hardening opt-out env at :77. Value 4 because the residual reads every binding on the acceptance path as unconditional, leaving a scorecard and method question with no product effect; difficulty 3 because the code half is one conditional in a single console auth module already covered at test_ui_hardening.py:322, and the cleartext branch is decided by the browser's own rule that a __Host- cookie requires Secure, not by engineering. | -| 174 | **#1189** | research an honest pass for ASVS 15.1.4 -- risky-component highlighting when the consolidated table is policy-withheld from the tree | 4 | 3 | _fill-in_ | P3 | The constraint is intact and is stated policy rather than oversight: the blanket rule at .gitignore:170 keeps THREAT-MODEL.md out of every engine checkout (git ls-files docs/security = 0), leaving only the per-library ADR notes. Value 4 because a first deployment's operators would receive no risky-component highlight for the hostile-input parsers or the auth and crypto stack, which is documentation coverage rather than a product defect; difficulty 3 because the deliverable is a public-safe highlight that survives the withholding subtraction plus an owner ruling on who application documentation serves -- prose and a decision, with no code path. | -| 175 | **#1190** | research an honest pass for ASVS 15.1.5 -- which dangerous-functionality classes can be highlighted publicly without becoming the roadmap | 4 | 3 | _fill-in_ | P3 | Only the dynamic-execution class is highlighted in-tree, the other classes the item names appear in no shipped document, and docs/security/ is still ignored at .gitignore:170. The remainder is deciding which classes can be named publicly without the highlight becoming the exploitation guide, then writing them where an adopter receives them. | -| 176 | **#1210** | the connscale FD/RSS peak has no provenance, so a stale-ppid subtree adoption becomes the reported number | 4 | 3 | _fill-in_ | P3 | The misresolution class that produced 35,600 handles at N=12 is prevented by the validated walk, so the residual is auditability of a load-harness gauge with no product or PHI effect. Difficulty 3: cpu_pids already is the covering set on both platforms, and the change threads it into the FD/RSS record, the report field and its tests. | -| 177 | **#1067** | Rule 3c governs by PATH PREFIX, so an independent repo vendored under a governed root inherits its governance | 4 | 4 | _fill-in_ | P3 | Rule 3c still decides governance by an equality-or-slash-prefix test over the resolved git common dir, so an independent clone under a governed root inherits governance, and the deny text still asserts a shared .git that a vendored clone does not have. Value 4 because it is a false deny in developer tooling with no product surface; difficulty 4 because any fix must pin both directions at once -- allow the vendored clone while keeping the nested .claude/worktrees case denied -- and it forces the git-submodule question the item deliberately deferred. | -| 178 | **#1127** | research an honest pass for ASVS 5.1.1 -- a per-feature file inventory that stays true, against a doctrine that treats enumerations as liabilities | 4 | 4 | _fill-in_ | P3 | Both concrete defects are fixed in shipped text: the SCP is enumerated as the fourth file surface and the 16 MiB inflate ceiling the item said appeared nowhere under docs/ is now documented. The remainder is the half the item was actually filed for -- an inventory that stays true as connectors are added -- since the block states it is hand-maintained and the drift test pins tokens rather than deriving the surface list from code (value 4, difficulty 4). | -| 179 | **#1157** | research an honest pass for ASVS 10.2.1 when PKCE and state are unconditional but the code flow ships off | 4 | 4 | _fill-in_ | P3 | The code is not in dispute and I confirmed it: PKCE S256 is hardcoded at auth/oidc/flow.py:215 with no configuration path, and oidc_enabled still ships False at settings.py:1942, so what remains is purely the scorecard-method question. Value 4 because the item states there is no product effect and the researcher is explicitly barred from changing the method, leaving a verdict argument as the deliverable. Difficulty 4: no code lands, but the reading has to be argued from ASVS 5.0.0 front matter and conformance guidance against a standing owner ruling, which is more than a doc edit. | -| 180 | **#1183** | research an honest pass for ASVS 13.3.2 -- least-privilege secret access, starting with whether the recorded verdict is even right | 4 | 4 | _fill-in_ | P3 | The item concedes no direct product effect and is explicitly an assessment-quality gap: the stated basis for the recorded partial is refuted by a sibling cell, so the work buys record accuracy rather than a control. Difficulty is a re-derivation against the pinned verb plus a survey of what the engine could introspect about its own secret-store privileges across Vault, environment-injected passwords and wincred, with no engine code change indicated. | -| 181 | **#1213** | NCPDP codec -- additive tolerant codec on payload-agnostic ingress, split from #78 | 4 | 4 | _fill-in_ | DEMAND-GATE | Pharmacy claims sit outside the HL7, FHIR, X12 and DICOM scope the project has taken on and no feed exists, so it is breadth rather than a gap anyone is blocked by. Difficulty is the shape shipped twice already, a pure tolerant codec under parsing/ riding payload-agnostic ingress behind its own content_type, with no transport registration, store work or backend matrix, which keeps it below the new-connector band. | -| 182 | **#1265** | the warning sign is unsanctioned decoration in 496 places across 80 files | 4 | 4 | _fill-in_ | P3 | Re-censused at HEAD with the ledger counts as the positive control the item demands: 499 occurrences across 81 files, with docs/BACKLOG.md at 121 and BACKLOG-CLOSED.md at 93 both reproducing, so nothing has been swept and the population has drifted up from the filed 496 across 80. Value 4 because nothing is mis-parsed and no behaviour depends on it, leaving a real but consequence-free vocabulary defect; difficulty 4 because each site needs an editorial replacement word rather than a mechanical substitution, the two ledger files must be sliced last to protect the status alphabet backlog_status_check.py genuinely parses, and whether retired ADRs are rewritten at all is still an open decision. | -| 183 | **#237** | Per-argument input modes (static templated dynamic) in the Steps view | 4 | 5 | _fill-in_ | P3 | The row contract is still the binary literal-or-not split at lens.py:286 with no mode concept on either side of the seam, and the ten-way taxonomy step 1 needs is not computed anywhere. Value stays 4 as authoring polish with no runtime effect; difficulty 5 covers a new ast classifier, a row-contract change and a per-argument mode selector in the webview, which is more than exposing an existing field. | -| 184 | **#1057** | Rule 3d has no occupancy signal, so it cannot tell an abandoned worktree from a live one | 4 | 5 | _fill-in_ | P3 | The remedy half shipped and the occupancy half did not, on the installed hook as well as the repo copy, which is exactly what the banner claims. Value 4 because the rule fails closed with no data loss and no security effect, the cost being a correct refusal the caller cannot act on; difficulty 5 because the installed hook lives outside every checkout and cannot dot-source scripts/coord/occupancy.ps1, forcing one of three structural options each carrying a drift or fail-open hazard, and whatever is built must also carry the 36-hour age rule that did the actual vetoing. | -| 185 | **#1115** | research an honest pass for ASVS 2.4.2 -- whether human-timing pacing is meaningful for an engine whose only human surface is the console | 4 | 6 | _money pit_ | P3 | The research half is delivered and the code it was written against is unchanged: the /ui surface charges nothing (zero allow_admin_write references in the web console) and the only pacing is a per-request per-actor budget at config/settings.py:2017-2021, never a flow timer. Value 4 because on a first deployment this is a coverage and calibration gap on an admin surface rather than a data-plane exposure; difficulty 6 because a flow timer spanning login, MFA enrolment and approve-then-decide is a new mechanism across auth service, API and console, and the floor has to come from a measurement the record does not have. | -| 186 | **#1154** | research an honest pass for ASVS 8.3.2 -- directory-sourced authorization changes that lag, fail open, and have neither named mitigation | 4 | 6 | _money pit_ | P3 | The 300 s directory reconciliation interval is unchanged at config/settings.py:1904 and its validator at :2085-2091 constrains only the range, so no alert-on-action-while-unauthorized and no revert exists and the requirement's alternative arm is entirely unbuilt. Value 4 because the engine-owned half is already strong (opaque tokens, per-request identity re-resolution, revoke-on-mutation) and what would be built is alerting and revert over a lag in data the engine does not own; difficulty 6 because both named mitigations need definition before either could be built, and revert has no meaning for delivered clinical messages. | -| 187 | **#1167** | research an honest pass for ASVS 11.2.4 -- constant-time recovery-code verification without turning ten argon2id slots into an amplification target | 4 | 7 | _money pit_ | P3 | The data-dependent early return survives on the shipped MFA path: _verify_second_factor walks the argon2id recovery hashes and returns on the first match, so the number of ~64 MiB verifications is a function of which code was presented. Value 4 because the leak is a wall-clock signal on an already-authenticated second factor rather than a bypass; difficulty 7 because the obvious constant-time loop multiplies a 64 MiB argon2id verification by the slot count on every attempt, converting a timing leak into a memory and CPU amplification target, and the evidentiary half has no precedent in this tree. | -| 188 | **#352** | Consult on enterprise AV coverage for SFTP- and file-connector ingest from outside the domain (ASVS 5.4.3 premise check) | 3 | 1 | _fill-in_ | P3 | The premise the consult exists to test is unchanged: the scan seam ships and the remote-file poller actually reaches it at remotefile.py:1020, so the na on 5.4.3 rests on the enterprise covering outbound-initiated pulls rather than on the product being unable to screen them. The deliverable is a recorded set of answers rather than code. | -| 189 | **#1010** | No licence-header gate exists in any language, and 196 first-party sources carry no SPDX tag | 3 | 2 | _fill-in_ | P3 | I attacked the shipped claim limb by limb rather than trusting the item's own SHIPPED banner. Checker exists AND is the right shape: scripts/quality/licence_header_check.py asserts the VALUE (EXPECTED_IDENTIFIER = "AGPL-3.0-or-later", WRONG reported as its own class), requires the tag to be a comment in the language's own prefix within HEAD_LINES=20, so a header-emitting string literal does not count. I ran it live rather than reading it: whole-tree run exits 0 with "1309 file(s) checked, all declare AGPL-3.0-or-later", so the backfill including the five Apache-2.0 files is done. I proved it can go red by planting both classes in the scratchpad (never in the repo): it exits 1 and names WRONG and MISSING separately. Extension scope matches the item's six languages exactly -- COMMENT_PREFIXES covers py/ps1/sh/ts/js/go, and --list yields 1147 py + 108 ts + 48 ps1 + 4 js + 2 sh, which equals the full tracked count for each of those extensions, so no tree is excluded and no in-scope language is uncovered (.go is 0 because #1011 retired the Go tree). Both callers are wired: .pre-commit-config.yaml:180-184 as a local hook with files: \.(py\|ps1\|sh\|ts\|js\|go)$, and .github/workflows/ci.yml:351-353 inside the required `test` job, deliberately NOT paths-filtered. The negative control ships with the gate in tests/test_licence_header_gate.py (four planted classes plus test_the_real_tracked_tree_is_clean, which enforces the invariant in the ordinary pytest job). Adding commits 4d3a72eb and 587ea4b8. So the "no gate exists / 196 headerless files" gap is genuinely gone. It is not confirmed_shipped because the security record still says otherwise in two places, one of them a deviation register whose own neighbouring entry states that naming a nonexistent control there is the worst place to do it -- and because the ledger closure is untaken, which the item's own banner says is the only protection against a rebuild of merged work on any other checkout. Remainder value 3: nothing runnable ships, but discharging a stale risk acceptance and preventing a duplicate rebuild of a landed quick win is worth more than marginal. Difficulty 2: doc edits plus a three-edit coupled closure that backlog_status_check.py hard-errors on if done partially. | -| 190 | **#1173** | research an honest pass for ASVS 11.5.2 -- an RNG-under-demand claim that survives the IDE extension staying in scope | 3 | 2 | _fill-in_ | P3 | The specific surface the item said held the cell is gone: cspNonce.ts draws 144 bits from node:crypto and a shipped test pins that no production source uses Math.random for a nonce, so the scope-ruling temptation the item exists to refuse no longer has to be taken. The remainder is the item's evidence question plus a verdict edit in a record this repo does not hold (value 3, difficulty 2). | -| 191 | **#1228** | Mail expiry is silent in both directions, so a swept message is indistinguishable from one never sent | 3 | 2 | _fill-in_ | P3 | I tried to prove the gap stands and mostly failed -- the fix landed at HEAD~2 in 14c4db18 (PR #460, 2026-08-20), and the item's banner is simply stale. RECIPIENT NAMING limb: SHIPPED. scripts/hooks/mail-drain.ps1:578 declares $expiredIds, :820 fills it only on a WON claim, and :1005-1008 appends "EXPIRED AND NEVER SHOWN ... ... They are in box/$key/expired/". $expired is inside the $anything presence test at :1056, so a drain that sweeps and shows nothing still emits. SENDER limb: SHIPPED. :828-836 writes a receipt with disposition 'expired-unshown' and a deliberately EMPTY observedUtc, and a write failure is counted ($expiredReceiptFailures, :843) and reported (:1010-1012) rather than swallowed. Receipts live at mail-root `receipts/`, not inside box/$key/ (scripts/coord/mail.ps1:594), so the sender can read it without touching the recipient's box -- the item's exact acceptance wording. The item's "how to prove a fix" is met literally: tests/test_session_mail.py:1624 asserts BOTH halves against ONE drain run with a fresh message as a negative control, and :1676 pins the no-expiry path. I checked the symbols are CALLED, not merely present, and that mail.ps1:481-487 replaced the wrong "-Status" pointer with the receipt path. What defeats confirmed_shipped is the third limb the item lists under "WHAT SURVIVES ... and it is still worth building": the signal is transient. I verified by grep that `Sub 'expired'` occurs zero times in scripts/coord/mail.ps1, so neither reporting mode can answer "what did I lose" after the drain's turn ends. The scope ambiguity is real and I state it rather than hide it: the item's narrowing sentence names only two limbs ("sender-side signal plus naming the swept message in the recipient's count"), which are both done, while its bullet list and candidate (b) name this third one. Per the pass rule, an unbuilt limb makes this partly_shipped, not confirmed_shipped. Remainder is a small additive change on an existing seam -- one more Get-BoxMessages call, a column, a named block, two doc edits -- against a suite that already covers the channel, hence difficulty 2; value 3 because the two directions that made the loss invisible are closed and the residual is internal coordination-tooling durability plus a doc enumeration that is now incomplete. | -| 192 | **#1267** | the tick rubric asks only about cadence, so it cannot express a fanout fault and its change-test conceals a stoppage | 3 | 2 | _fill-in_ | P3 | The artefact is not in the repo at all, so I went to the live file the item names: %USERPROFILE%\.claude\mefor-usage\seat-tick.ps1 (39113 bytes, mtime 2026-08-20 13:25, plain ASCII, verified with `file` + xxd so a null grep is not an encoding artifact). What I found refutes "fully shipped" in both directions. The shipped part is real but incidental: a comment at :277-285 records a 2026-08-20 owner-instructed REWRITE that deleted the entire diagnostic rubric -- "QUANTISED", "FANOUT", "DELIVERY fault", "Act only if something actually changed", "Waking is not a reason to do work" all return 0 hits, and the body is now a 357-char bare wake-up (seat-tick.bodylen reads 357). So the offending sentences are gone. But they were removed for an unrelated reason (do not spend a turn analysing the tick), and the item's actual deliverables did not land. The replacement question is absent; the tick still carries a pending/change test ("Nothing pending -> do nothing") of the same shape the item says is compatible with total stoppage; and the Verdict's explicit second half, a check that the prose cannot silently revert, does not exist -- the three guards at :313, :324 and :347-357 assert the opening marker, the length cap, and length CHANGE, none of which can see a semantic revert. I also checked for relocation rather than assuming deletion: grep -rli "quantised" over the checkout hits only docs/BACKLOG.md, harness/load/connscale/probe.py (unrelated) and a pygments file in .venv, and "FANOUT SKIP" appears in the repo only inside #1267/#1266 prose, so the rubric was not moved into roles/ or docs/. The shared prerequisite is independently disproven from the state files themselves rather than from the item's claim about them. Item is still open in the live ledger (banner is the open numeral at docs/BACKLOG.md:9976) and absent from BACKLOG-CLOSED.md. Value drops from the filed 5 because the two worst sentences are genuinely gone and severity is none with no deployment axis; difficulty stays low and if anything fell -- the near-miss that dominated this item was the 2000-char cap with 40 chars of headroom, and headroom is now 1643, so the fleet-killing edit hazard is much reduced. Not cannot_determine: I could read the evidence, it just lives outside version control. | -| 193 | **#98** | Kerberos SSO channel-binding (EPA) opt-in + acceptor-enforcement spike (P3, on-trigger) | 3 | 3 | _fill-in_ | DEMAND-GATE | The acceptors are still built with no bindings and no CBT knob exists anywhere in the engine, not merely under auth/, so the remainder is the spike plus at most one conditional per-mode flag on the in-process-TLS path. It stays a niche interop knob that is structurally void behind a TLS-terminating proxy, and the trigger has not fired: no deployment wants EPA and no domain-joined lab box exists to answer the acceptor-enforcement question. | -| 194 | **#148** | X12 TA1 interchange-acknowledgement generation | 3 | 3 | _fill-in_ | DEMAND-GATE | The pyx12 walk yields a conforming 997/999 for free, so only a contract mandating interchange-level accept or reject reaches for TA1, which is a niche interop knob. Difficulty 3 rather than 2 because interchange.py splits on ISA and IEA but validates neither the IEA control number nor the group count, so the build is envelope integrity plus the A/E/R note-code table plus a TA1 envelope emitter, not one function beside the splitter. | -| 195 | **#159** | TCP stream-until-close (no-framing) mode | 3 | 3 | _fill-in_ | DEMAND-GATE | Gap stands: FrameCodec.__post_init__ still rejects start == end (framing.py:63) and codec_for still demands a preset or both delimiter bytes (framing.py:167-170), and tcp.py:68-77 has no path around the codec, so close-framing is inexpressible. A niche transport-breadth knob (value 3) whose remainder is a read-to-EOF branch on the source plus write-then-close on the destination, i.e. one new setting on one connector (difficulty 3). | -| 196 | **#183** | SOAP MTOM/XOP binary packaging | 3 | 3 | _fill-in_ | DEMAND-GATE | No MTOM, XOP or multipart/related handling exists anywhere in the SOAP destination, so binary still goes out base64-inline in the string-concatenated envelope. The work is confined to that one connector, and the trigger, a SOAP document-exchange partner requiring MTOM that a migration depends on, has not fired. | -| 197 | **#320** | windows-2025 is the slowest CI leg (1.8x-3.5x), but that does not explain the 60/s failures | 3 | 3 | _fill-in_ | P3 | Diagnosis only: the CI symptom was absorbed by #115's widened stranding budget, nothing was lost, and the record-correction half is in the tree at report.py:596-606. What is left is a single measurement under concurrent load driven from an existing dispatch-only workflow, so both worth and cost stay small. | -| 198 | **#1120** | research an honest pass for ASVS 3.4.4 -- proving nosniff on every response, not on the responses someone remembered | 3 | 3 | _fill-in_ | P3 | I attacked the shipped claim first and it survives in part -- this is not an anchor-moved false negative. The item's cited anchor app.py:1257 is genuinely gone because the header literals moved to the new api/header_floor.py, and commit c49a8a7b (PR 422, confirmed ancestor of HEAD) built exactly the construction the item asked research to find: a pure-ASGI setdefault floor registered outermost, plus a hand-set baseline on the unhandled 500 because ServerErrorMiddleware sits outside user_middleware by construction. Both of the item's originally measured escape paths are closed and tested, with a vacuity guard and negative controls in tests/test_api_security_header_floor.py. So limbs A (construction), B (can Starlette ordering reach pre-middleware rejections) and C (an admissible measurement) landed. Then I attacked the other direction, because a research item closes on its verdict, not on a commit. The record of record holds the cell at partial as of a re-verification dated 2026-08-17 whose reviewer line names PR 422 itself -- so the assessment was made WITH the floor in the tree, not before it. Its arithmetic: four response-emitter classes derived structurally from create_app's registration order and the scope-type guards, two covered, two not. I verified both uncovered classes against HEAD rather than trusting the record: `websocket.http.response` returns 0 occurrences under messagefoundry/ against 7 `websocket.close` sites as positive control, and header_floor.py:91-93 still returns early on non-http scopes. I also confirmed none of the cell's own re-score triggers has fired since that measurement -- app.py:5317 still registers the floor last, BASELINE_SECURITY_HEADERS still carries the nosniff pair, and git log b8c22cc8..HEAD shows no change to header_floor.py or client_networks.py. Two failure modes I deliberately checked against. A similarly-named symbol is not the fix: I read header_floor.py end to end and traced its registration, rather than counting the module's existence as closure. And a moved anchor is not an open gap: I confirmed the setdefault relocated rather than vanished. The distinction that decides this item is that the shipped work is the honest construction the item wanted and still does not reach the honest pass the item is named for, which is partly_shipped, not confirmed_shipped. Value 3 on the remainder rather than the filed 4: the practically important half closed, since the JSON error bodies that motivated the filing now carry the header, and a websocket 403 has no body a browser sniffs. It is not 1 or 2 because real runnable work is left -- the denial-response emitter plus the guard widening -- and because a shipped security module currently justifies its scope bound on a premise the engine's own adjacent comment contradicts, which the project treats as a defect class in its own right. Difficulty 3: a small additive change on an existing seam, but it needs an ASGI extension-capability check with a fallback to the bare close, a middleware guard widened without weakening the network gate that sits inside it, and tests across all three. No migration cost is priced in -- nothing is deployed, so there is nothing to break. One scoping note on sourcing: the verdict itself lives in the maintainer-internal scorecard, which I could reach because the vault clone is checked out beside this repository. I have deliberately not reproduced cell content beyond the fact that 3.4.4 holds at partial and the emitter-class count, since coverage detail over a published catalogue discloses the complement by subtraction. | -| 199 | **#1161** | research an honest pass for ASVS 10.5.4 when the ID Token audience check is unconditional but the relying party ships off | 3 | 3 | _fill-in_ | P3 | Both halves hold at HEAD: the audience check runs unconditionally in auth/oidc/claims.py:230-240 and oidc_enabled is still False by default, now at config/settings.py:1942 rather than the cited :1880, which is itself an instance of the anchor drift the item warns about. Value 3 because the code is correct and the deliverable is a record correction under a standing owner ruling with no product effect on any default; difficulty 3 because no code change is even available -- enabling federation by default is ruled out by the item -- so the remainder is a re-anchor by token plus an argued position and an owner call. | -| 200 | **#1254** | a required check is named for its subject, not its assertion, so a passing suite reds under a label that says the tests failed | 3 | 3 | _fill-in_ | P3 | The measured cost, a seat reading the label and forming the wrong hypothesis, is now met at the point of failure by the gate's own first line, so what is left is the optional and dangerous half. Difficulty 3 prices a decision between leaving it, splitting the margin gate into its own non-required check, and the atomic rename across the workflow, required-contexts.txt, the pinned count and branch protection, which the item correctly flags can wedge the repo if ordered wrong. | -| 201 | **#83** | Rich file-output disposition + FTPS / SFTP variants | 3 | 4 | _fill-in_ | DEMAND-GATE | Both halves stand at HEAD: FileDestination's constructor enumerates its settings at file.py:204-225 and none of append, dated-subfolder archive or header/trailer framing is among them, and remotefile.py builds explicit FTP_TLS at :291 with no implicit-mode, passive-toggle or keyboard-interactive path anywhere in the file. Niche per-driver interop that no shipped feed needs. | -| 202 | **#110** | DICOM Study/Series Instance UID de-duplication on the C-STORE SCP | 3 | 4 | _fill-in_ | DEMAND-GATE | No Study or Series UID de-duplication exists on the C-STORE SCP: transports/dicom.py commits per object at _on_c_store (:273) and _commit (:368) with no seen-UID state, and grep over that module returns no StudyInstanceUID or SeriesInstanceUID reference at all, while parsing/dicom/peek.py:105-106 exposes both UIDs to a Router that cannot hold cross-message state. Niche DICOM-only study collapse; the remainder is a durable connector-side seen-UID ledger on the processed_files precedent (store/base.py:845, prune_processed_files at :858) plus a FILTERED disposition on the suppressed objects, across three backends. | -| 203 | **#178** | SFTP cipher / KEX / MAC allow-lists | 3 | 4 | _fill-in_ | DEMAND-GATE | Only a FIPS-restricted or otherwise pinned partner endpoint reaches for this knob and nothing on shipped defaults is wrong without it, which holds value at the niche-interop band. Difficulty 4 rather than 3 because the item makes a CI leg carrying the sftp extra mandatory in the same change (no leg installs paramiko today), and the preferred-ordering clause of the Scope must be set on the Transport before negotiation, which restructures _make_client rather than adding a kwarg. | -| 204 | **#1232** | `search_presets.owner` holds an `Identity.user_id` after #1225, so the column name is now misleading | 3 | 4 | _fill-in_ | P3 | The column is still named owner and is still the key in every preset path (store.py:1743, :8053), with 128 preset references across the four store modules. Value 3 because nothing behaves differently and the corrected comment already states which form the column holds, so the cost of leaving it is a reader's wrong inference; difficulty 4 because the rename is a cross-backend change tested on SQLite, PostgreSQL and SQL Server, which is the rubric's own rung-4 text. | -| 205 | **#1017** | worktree_gate rule 3d has no ownership signal, so it denies a session removing a worktree it created itself | 3 | 5 | _fill-in_ | P3 | The harm the item priced -- a deny asserting as fact that the target belongs to another session -- is closed: the self case has accurate text and the other branch declares that it has no authorship signal. Value 3 because what remains is an honestly-worded refusal with working fallbacks and no product surface, plus the branch-deletion prose error; difficulty 5 because a real ownership signal still means recording creation provenance at worktree add time, a verb the rule does not even match today, and the payload carries no session identity (0 hits). | -| 206 | **#1145** | research an honest pass for ASVS 7.1.3 -- federated session-lifetime coordination when the Kerberos leg exposes no ticket lifetime | 3 | 5 | _fill-in_ | P3 | Two of three mechanisms ship and the Kerberos limb is untouched: no ticket end time is read anywhere in ldap.py and ADR 0079 still records the cell as accepted-not-built with its promotion trigger unfired. Value is low because the cell carries a signed risk acceptance with a 2027-01-14 review and there is no product effect; difficulty stays mid-band because the honest answer depends on measuring whether any non-SSPI ticket-lifetime source exists on the targeted platforms without forking pyspnego, which the project has ruled out. | -| 207 | **#1153** | research an honest pass for ASVS 8.2.4 -- adaptive controls inside a live session on a single-host loopback deployment | 3 | 6 | _money pit_ | P3 | The one in-session contextual signal still ships off and is documented in the shipped code as never changing an authorization decision, and the allowed-networks list still ships empty with a recorded inert-behind-NAT limit, so the existing-session half of the requirement rests on nothing at the shipped bind. Value 3 because no signal changes an authorization outcome in any configuration, so what is bought is a cell rather than a closed exposure; difficulty 6 because the honest answer needs an owner boundary decision first and the four attribute classes the verb names are largely unavailable to an on-premises single-host engine, with time-of-day gating on a 24-hour clinical service actively harmful. | -| 208 | **#1174** | research an honest pass for ASVS 11.7.2 -- encrypt-after-use for plaintext that lives in immutable CPython objects | 3 | 6 | _money pit_ | P3 | Unchanged in code: only the cipher's mutable buffers are locked and zeroized, and the caller's plaintext str plus the bytes cryptography returns are still immutable with no wipe hook. Value is low because exploitation requires memory access to the engine process, making it defence in depth; difficulty is 6 because the item forbids narrowing the whole-message Handler contract, so the buildable arc is a field-scoped view or decrypt-on-access accessor across store crypto and the transform path, which is cross-cutting but touches neither the stage handoff nor the ACK contract. | -| 209 | **#155** | Server-to-server migration runbook | 2 | 1 | _fill-in_ | DEMAND-GATE | Every constituent step still ships documented and separately in docs/EARLY-ADOPTER-GUIDE.md (section 4 install, 10 backup/restore/DR, 13 upgrade and rollback, 16 decommissioning) and no page stitches them end to end -- the phrase occurs in docs/ only inside BACKLOG.md. Value 2 because the capability is complete and following the sections in order is a clean if awkward workaround; difficulty 1 because it is one new doc and no code, the care being the quiesce and drain ordering. | -| 210 | **#1061** | Rule 3c fails open when the primary is named by a relative path, disarming every worktree's commit gates | 2 | 1 | _fill-in_ | P3 | Nothing buildable remains under this item's own title: the relative-path fail-open denies on main in the two-step shape it prescribed (scripts/hooks/worktree_gate.ps1:1031 roots the target via Get-FullPathRaw at :147, :1032-1050 is the fail-closed deny, :1054 resolves the common dir against the rooted target), the fail-closed branch is tested at tests/test_worktree_gate_control_plane.py:332, and the installed hook is byte-identical to the repo copy at sha256 61a63c95, refuting the banner's still-commit-a67838d2 sentence. Value 2 and difficulty 1 because what is owed is a ledger correction plus repairing #1071, which asserts the gate asks rev-parse --absolute-git-dir when that string returns zero hits against a git-common-dir control of four. | -| 211 | **#1202** | the vault ASVS gate runs a verifier this repo owns, on a bare interpreter, and nothing here checked it would run | 2 | 1 | _fill-in_ | P3 | I split the item into the limbs it names and checked each against HEAD rather than against its anchors. Limb 1, the verifier exists at the literal path both vault workflows hardcode: scripts/asvs/scorecard.py is present and tests/test_asvs_verifier_vault_contract.py::test_the_verifier_is_at_the_path_the_vault_hardcodes asserts it with VERIFIER_REL hardcoded to the same string. Limb 2, the stdlib-only contract enforced by an ast scan that sees DEFERRED imports (the load-bearing half per the item): import_roots uses ast.walk, not a module-level scan, and it also traps relative imports as "" since scripts/asvs has no __init__.py. Limb 3, the bare-interpreter smoke run: test_the_verifier_runs_as_a_bare_script_with_nothing_installed shells `python -I -S --help` from tmp_path, which reproduces the vault's sys.path[0]=scripts/asvs invocation, and it asserts on stdout containing "--scorecard" rather than on the exit code alone. Limb 4, the detector is driven: eight parametrized mutation cases (httpx, fastapi, yaml, a function-body pydantic, a branch-deferred requests, two messagefoundry first-party shapes, a relative import) plus a negative control asserting 6 roots and zero non-stdlib. I could not run pytest here (no pydantic in this checkout, so tests/conftest.py fails to import), so I did not take the test file's existence as proof. Instead I executed the module's own main() -- `python tests/test_asvs_verifier_vault_contract.py` printed 19 import statements across 2614 lines, all STDLIB, rc=0 -- and independently ran `python -I -S scripts/asvs/scorecard.py --help`, rc=0. I then re-implemented the ast scan myself over all four MIRRORED_TOOLS as a control: scorecard.py, asvs_tally_lint.py, asvs_residual_lint.py, prove_report.py all return zero non-stdlib roots, so the guard's green is a real property of the tree and not a scanner that stopped seeing things. The one thing I found that the claim glosses over is not a gap in the fix but a drift in its description: the guard was moved into the tooling partition after it landed, so the item's "the required `test` leg runs this module" is false at HEAD even though the merge gate still covers it via `CI gate`. The third vault claim -- that recorded evidence anchors still resolve -- is explicitly out of scope in the item and needs the private MessageFoundry-vault clone I cannot read from here; that is a stated non-goal, not an unbuilt limb, so it does not make this partly_shipped. Scoring the remainder: a banner flip and two stale sentences ship nothing runnable, hence value 2 rather than 1 only because something genuinely is left, and difficulty 1 as a doc edit. | -| 212 | **#1221** | A doc lint crashes when printing a hit that contains a glyph, so it is unreadable exactly when it fires | 2 | 1 | _fill-in_ | P3 | Enumerated three limbs from the item's own text and checked each: (a) decide harden-stream vs treat-as-corpus-problem, (b) implement it in the lint, (c) prove it ON THE FAILURE PATH by making the lint FIRE on a glyph-carrying document. All three landed in f298630f (PR #346, 2026-08-12), bundled under an unrelated #1220 commit title, which is why a title-level search misses it. I refused to accept the presence of a function named `_harden_stdout` as proof and ran the control in BOTH directions from a scratchpad copy, editing nothing in the repo. Positive: a temp doc carrying a synthetic AGAINST_TOTAL tally and an arrow glyph, under PYTHONIOENCODING=cp1252 exits rc=1 and prints the path, line and idiom label for that hit -- legible, no traceback. Negative control: the same script with only the single line `_harden_stdout()` neutered dies with `UnicodeEncodeError: 'charmap' codec can't encode character '->' in position 69`, raised from `print(f" {h.path}:{h.line}: ...")` at line 372 -- the item's exact failure site and exact character. So the fix is load-bearing rather than decorative, and the defect is genuinely gone. The test limb is real coverage, not a similarly-named test: tests/test_asvs_tally_lint.py:317 drives the lint as a SUBPROCESS with PYTHONIOENCODING=cp1252 (deliberately, because pytest's capture wrapper is one of the objects the hardening guards against), then asserts no UnicodeEncodeError, rc==1, and that the path/line/idiom survive. That is the item's own stated proof bar. I also checked the anchor-moved failure mode in reverse: the item's cited `:245` and `:63-64` line numbers have shifted (the rglob is now :246, the BACKLOG reference now :64-65), but the mechanism is present and fixed at those moved lines, so the stale anchor is not evidence of anything. Remaining doubt is confined to which reconfigure idiom was chosen: the item's option A said "forces UTF-8 on its own stdout" while citing messagefoundry/__main__.py as the proven in-tree exemplar, and __main__.py:55 itself uses bare `errors="replace"` -- so the shipped fix matches the exemplar the item named, and I do not score the difference as an open limb. | -| 213 | **#1253** | one control-char predicate written out seven times across six files | 2 | 1 | _fill-in_ | P3 | I attacked the shipped claim on the two limbs that can fail independently -- consolidation and adoption -- and could not break either. Consolidation: git grep for the predicate across messagefoundry/ returns it in controlchars.py only, once as implementation (controlchars.py:67, inside a single _is_control_char) and once in the module docstring naming what it replaced; parsing/sniff.py:179 is the byte-wise allowlisted variant the item explicitly excluded and transports/rest.py:110 is the docstring prose it excluded, so both exclusions are honoured rather than folded in. Adoption: I enumerated importers rather than assuming them and got exactly the six files the item names, with seven call sites matching its own table -- config/codeset_edit.py:43/:306, config/impact.py:47/:632, transports/dicomweb.py:48/:143, transports/fhir.py:50/:179 and :202, transports/remotefile.py:67/:122 (all has_control_char), transports/rest.py:57/:112 (strip_control_chars). The reject/strip split the item warned must not be simplified is preserved: rest.py is the only strip site. tests/test_controlchars.py covers the module directly and pins the alphabet, including that chr(0x80) is deliberately NOT caught. So the seven-across-six count this item was filed over is genuinely retired, and calling it still_open would be the inverse error. What is NOT done is the closure the item's own dispatcher paragraph specifies as three coupled edits, and the recent ledger commits show the omission is real rather than merely pending: 3c1c1136 closed #1241 and #1271 as landed-by-content and 75e20d43 closed three more, while #1253 was left open. That is a doc edit, not a build. The eighth-spelling question is NOT this item's remainder: messagefoundry/logging_setup.py:70 restates the same code points as a translation table, but that is the filed subject of #1273 (docs/BACKLOG.md:10259), which also drove the collapse of controlchars.py's own two internal copies -- attributing it here would double-count. | -| 214 | **#1257** | an exception after `engine.start()` unwinds nothing, so a startup refusal hangs the process instead of exiting | 2 | 1 | _fill-in_ | P3 | Enumerated the item's limbs: scope option (1) fix the partial-startup teardown so a raise between engine.start() and the yield unwinds; option (2) was explicitly the inferior alternative and option (3) was recorded as REJECTED, so neither is owed. The item's proof standard is its own: raise in the post-engine span and assert the PROCESS exits, timed and read directly, not that SystemExit is raised. BUILD STATE. The try now opens at api/app.py:5838, one line above `await engine.start()` at :5839, and its finally at :6033 stops the upload-retention runner, cancels reconciler/reaper/bootstrap_reminder, calls engine.stop() (which ends in store.close(), pipeline/engine.py:1882) and closes both notifiers. The five names the teardown touches are hoisted to :5828-5832 with a comment attributing the hoist to #1257 and recording that removing one brought the hang straight back via UnboundLocalError inside the teardown -- so the known second-order trap was measured, not assumed. tests/test_lifespan_startup_unwinds.py exists and passes here (1 passed, 1.49s); it asserts termination under a 30s bound sized below the suite watchdog, carries three positive controls that the child actually reached uvicorn and refused, and pins the no-masking half on the absence of the CPython chaining banner rather than on the presence of the error string -- which the file itself records as non-discriminating. I tried to refute it four ways rather than trusting the test. Injecting a raise (a) at the end of a real engine.start(), (b) at the very start of engine.start() so stop() runs against a never-started engine, and (c) at auth.initialize() deep in the span, each produced uvicorn's correct refusal, a normal return from the child, process exit in about 1.2-1.3s, and NO "During handling of the above exception" chain. (d) I also probed the PRE-engine window the item asserts is already covered (load_config at :5816 and build_coordinator at :5734, both outside the try, both leaving the store open): three runs exited in ~1.2s, so even the unguarded pre-engine window does not reproduce the hang here -- it leaks the store and prints an aiosqlite worker traceback, but it terminates, so there is no second live limb to raise. The anchors in the item prose have moved (:5731 is now :5839) but the fix landed, on PR #394, at 4a8f062d. | -| 215 | **#1272** | the shell-syntax harness resolves whichever bash PATH orders first, and reports a harness failure as 160 content failures | 2 | 1 | _fill-in_ | P3 | The underlying defect is real and unfixed (tests/test_workflow_shell_syntax.py:110, tests/test_dependabot_automerge_guardrails.py:145, tests/test_installed_coord_hooks.py:666 all still use unpinned discovery, and the third resolves sh first), but #1216 is the surviving number for it and its amendment already absorbs this item's unique content, naming the three modules and the 127-versus-2 discrimination layer. The remainder specific to THIS number is closing it with a pointer, a ledger edit. | -| 216 | **#135** | Configurable statistics push / refresh interval | 2 | 2 | _fill-in_ | DEMAND-GATE | The push loop is still a hardcoded await asyncio.sleep(1.0) at api/app.py:5136 between the send_json and the revalidation check, and settings.py carries ws_allowed_origins at :752 with no stats-interval sibling anywhere, so the gap stands whole. Value 2 because a fixed 1s cadence serves live monitoring and there is no interop dimension; difficulty 2 for one validated settings field on an existing seam read by the loop that already exists. | -| 217 | **#173** | Segment/segment-group subtree-copy helper | 2 | 2 | _fill-in_ | DEMAND-GATE | Gap stands and stays thin: message.py exposes segments():193, add_segment():377, groups():470 and a whole-message copy():508, but no cross-message subtree copy exists anywhere in parsing/, so the item's own reading that the hard part is already done holds (value 2). The remainder is one additive method on an existing seam whose only subtlety is re-encoding across two messages' MSH separators (difficulty 2). | -| 218 | **#1019** | install-selfheal.ps1 has no installed-vs-source payload-parity instrument, and it wires the most privileged hook in the estate | 2 | 2 | _fill-in_ | P3 | The detection gap the item was filed for is closed from the pytest side, and the item's own text rules the installer-side readout unnecessary because install-gate.ps1 -Status refuses in-session too, so plain-terminal-only is the precedent rather than a gap. That puts the remainder squarely on the rubric's rung 2, marginal and already substantially covered, at difficulty 2 for a small additive change to one PowerShell installer with two worked examples to copy. | -| 219 | **#1020** | The first-run bootstrap Administrator is created with no email address, and the PHI notification gate cannot see it | 2 | 2 | _fill-in_ | P3 | The gate this item exists for is SHIPPED and wired: `_assert_security_notice_is_deliverable` (api/app.py:5322) runs in the lifespan at :5959 right after `bootstrap = await auth.initialize()` and refuses a PHI enforce instance when no enabled Administrator carries an address, with a lifespan wiring test and a warn-side positive control in tests/test_security_notice_deliverability.py; the uvicorn non-closure rider is discharged by tests/test_lifespan_startup_unwinds.py, which patches that gate's own call site, runs uvicorn in a subprocess and asserts the process exits. What is left is record repair -- ADR 0167 still reads Status Proposed with the false line that the consuming gate is not yet written, its filename asserts an early-lifespan placement that was not built, docs/DEPLOYMENT.md carries no exit-3 line, and has_notifiable_admin has zero non-test call sites. Value 2 and difficulty 2 because a false ADR record plus an orphaned predicate ship nothing runnable. | -| 220 | **#1160** | research an honest pass for ASVS 10.5.1 when the ID Token nonce check is unconditional but the relying party ships off | 2 | 2 | _fill-in_ | P3 | The control is complete and unguarded in the shipped code, so the cell is already substantially covered and only enablement holds it short. The item forbids both engine changes available to it, flipping oidc_enabled and adding a refuse-to-boot gate, and directs the shared optional-feature question to be answered once across all six siblings, so this item's own remainder is applying that ruling and recording the outcome, which ships nothing runnable and costs a record-grade edit. | -| 221 | **#1162** | research an honest pass for ASVS 11.1.1 -- a key-management policy covering all six keys, and an oversharing bound that does not fight escrow | 2 | 2 | _fill-in_ | P3 | The one-of-six scope gap and the missing oversharing bound are both answered in the shipped document, and the answer took the honest route the item demanded rather than re-declaring the scope. What is left is re-verifying the cell against that text and rewriting its residual, which is a verifier run plus a vault edit rather than repo work. | -| 222 | **#1176** | research an honest pass for ASVS 12.1.5 -- whether ECH is reachable at all before CPython exposes an API | 2 | 2 | _fill-in_ | P3 | Re-measured on the pinned runtime: an SSLContext exposes no ECH attribute, the routing half is unreachable from either authoring surface, and the Go sidecar the record leans on was retired from the tree, so the cannot-honestly-reach-pass finding is better supported now than when filed. Difficulty 2 because the deliverable is a recorded finding whose external blockers are confirmable in one probe and two greps. | -| 223 | **#1214** | The threat-model numeric-parity gate compares live constants to a hardcoded transcription, not to the document it names | 2 | 2 | _fill-in_ | P3 | The published defect, a failure message naming an artefact the check never opened, is fixed in place with its reasoning in the docstring, and two guards were added past the filing. The remainder is a one-identifier rename or the document-binding alternative the Scope explicitly makes optional, which ships nothing runnable either way, so value 2 and a small edit on an existing test module. | -| 224 | **#1224** | The `upload.prune` audit row names the file's owner as the actor of an automated sweep | 2 | 2 | _fill-in_ | P3 | Attacked the shipped claim at both cited sites and it survived on the code limb. Site 1 (request-path opportunistic sweep) is now messagefoundry/api/app.py:3918-3936: `actor="system"`, `client` argument removed entirely, and detail carries file_id + uploader + uploader_id, with a comment naming BACKLOG #1224 and citing ADR 0150 decision 4 for dropping the client. Site 2 (background UploadRetentionRunner) is messagefoundry/api/app.py:5891-5907: the lifespan-owned `_audit_upload_prune` closure also writes `actor="system"` with uploader/uploader_id in detail. Enumerated prune call sites repo-wide: `prune_expired(` is called at app.py:3918 and uploads.py:648 (inside `run_once`) only, so at least those two are the whole surface and both are attributed to the system. Where the claim fails is the item's explicit third limb -- "assert the emitted row's actor is the system principal and its uploader detail is unchanged, for BOTH the request-path prune and the background runner". Only the request-path assertion exists (tests/test_upload_api.py:794-814, which also asserts the null client). The runner-path test (tests/test_uploads.py:389) injects a list-appending stub for `audit=` and asserts `[m.file_id for m in audited]`; it never constructs or inspects an audit row, so it would pass unchanged if `_audit_upload_prune` were reverted to `actor=meta.uploader`. That closure is reachable only from the API lifespan, and no test drives a lifespan, so the fixed line has no execution coverage at all. Under the not-deployed rule this is worded conditionally: the false attribution would no longer be written on a first deployment from either path, but the path the item singled out as the one a partial fix abandons is guarded by nothing. | -| 225 | **#1291** | Get-HandledTools returns NULL on its zero-tool and missing-file paths, correct only by its caller's grace | 2 | 2 | _fill-in_ | P3 | Unchanged: both exit paths at install-gate.ps1:244 and :250 still unroll, and the sole caller at :302 still re-wraps, so shipped behaviour is correct and the trap is latent exactly as filed. Value 2 is rung 2 -- developer tooling with no product, PHI or live-defect axis, and the one caller already covers it. Difficulty 2 for the two-token fix plus a test that must reach the function through ParseFile and FunctionDefinitionAst, since dot-sourcing install-gate.ps1 performs a machine-global install and a test routed through -Status cannot tell fixed from unfixed. | -| 226 | **#156** | Alert hysteresis (separate fire/clear thresholds) | 2 | 3 | _fill-in_ | DEMAND-GATE | The only occurrence of clear_depth or clear_oldest_seconds in the tree is the backlog's own scope sentence, and matching is still single-sided at alert_sinks.py:625-631 against the flat realert_seconds (settings.py:2919) plus per-rule cooldown_seconds (:2774). Value 2 is rung 2 -- an anti-flap refinement substantially covered by a genuine throttle. Difficulty 3 for two new AlertRule fields plus a clear-side matcher, with no store or migration work. | -| 227 | **#105** | Deterministic Corepoint-import tooling — Action-List → code-first scaffold (P3, deferred, owner decision) | 2 | 4 | _fill-in_ | DEMAND-GATE | Even finished this ships little worth: the adopter hand-ported, the AI /migrate covers the remaining path, and no demand is named, which is the substantially-covered band. The remainder is the deferred ide/ wrapper plus a reconciliation that needs an artifact the repository does not contain, behind the #313 multi-message Handler gate this item cannot buy. | -| 228 | **#1225** | Saved search presets are owner-scoped by the reassignable username, not the immutable `user_id` | 1 | 1 | _fill-in_ | P3 | Tried to prove the gap stands and failed on every limb. Checked the HEAD blob rather than only the working tree because git merge-base --is-ancestor 38ef285e HEAD returns false under squash-merge (the SDS-3.8 trap): app.py:4217 writes owner=identity.user_id, and :4169, :4247, :4282 read on user_id -- the write site the amendment added is the one that is fixed first. Looked for a bypass: webconsole/routes/search.py calls the same handlers across the CoreHandlers seam, harness/ and apiclient/ have zero search_preset references, and store.py/postgres.py/sqlserver.py take owner as an opaque string so no backend carries its own key. The item's own proof condition -- recycled name, not two live users -- exists as tests/test_search_presets_api.py:192 and passes (4 passed); it restores the orphan row after delete_user so it tests the KEY and not #1233's purge. Negative control: the #1226 screen, whose test asserts it caught all four preset sites pre-fix including the write, reports 8 candidates at HEAD with no preset site among them while still firing on the uploads and security_events_for shapes, so it is not blind. Schema comment store/store.py:1730 and ADR 0136 Amendment A both retract the username design. Value 1 and difficulty 1 price the remainder, which is a banner flip, not a build. | +| 116 | **#1350** | research an honest pass for ASVS 1.3.4 -- an attacker-influenced image/svg+xml attachment is accepted and served, so the neither-accepts-nor-renders premise does not hold | 5 | 3 | _fill-in_ | P2 | Scored 2026-08-23 at HEAD, NOT in the 2026-08-20 pass. An `image/svg+xml` OBX-5.2 attachment is accepted, stored and served from the download route, so the prior not-applicable rationale that the engine neither accepts nor renders SVG is false on the accepts conjunct. A layered control is SHIPPED and on: `api/app.py` classifies browser-active MIME by substring token (`html`, `xml`, `script`, `svg`), serves `default-src 'none'; sandbox`, forces `Content-Disposition: attachment` unconditionally and sets `nosniff` globally. Value 5 because the exposure on first deployment is bounded by that control rather than open, and the open question is its COMPLETENESS not its absence; difficulty 3 because the work is establishing whether the classifier can be evaded and whether the sandbox holds, not building a control. | +| 117 | **#1352** | research an honest pass for ASVS 11.4.4 -- key-derivation parameters that balance security against brute force, at every derivation site | 5 | 3 | _fill-in_ | P2 | Scored 2026-08-23 at HEAD, NOT in the 2026-08-20 pass. The pinned text's second sentence -- parameters must balance security and performance to prevent brute-force attacks from compromising the resulting key -- was TRUNCATED out of the prior assessment's quoted verb, and that dropped clause is the operative one. `auth/passwords.py` derives with argon2id via argon2-cffi and `auth/service.py` caps its concurrency; whether the parameters are recorded and justified, and whether every derivation site is covered, is unestablished -- the prior search was incomplete by its own account. Value 5 because a weak or undocumented parameter set would be invisible until challenged; difficulty 3 because the likely remainder is recording and justifying parameters plus a site inventory, not new cryptography. | +| 118 | **#81** | Alert escalation tiers + day/time thresholds + content (Action-Point) alerting | 5 | 4 | _fill-in_ | DEMAND-GATE | Both named remainder halves are absent at HEAD: content_match is only on the concrete notifier (alert_sinks.py:734) and appears in neither the AlertSink Protocol nor LoggingAlertSink, and AlertInstanceInfo (api/models.py:275-296) carries no escalation_tier despite the store already reading it back (postgres.py:4292). Value 5 is the parity/breadth rung with the escalation and schedule two-thirds already shipped; difficulty 4 because the load-bearing piece is exporting an alert emitter a Handler can reach, which has to be reconciled with the re-run purity rule rather than added to an existing seam. | +| 119 | **#124** | Batch-export message bodies from a connection log to a file | 5 | 4 | _fill-in_ | P2 | A scripted operator can already export through the audited API, so the remainder is a console affordance rather than a missing capability, which caps value at parity-with-a-workaround. Difficulty stays 4 because the proxy route must re-apply the step-up and the per-row can_access_channel check that a direct handler call skips, and _auth.py:533 records that export_messages paces its own PHI budget so the route must NOT pass phi= or it double-charges. | +| 120 | **#236** | Test-this-step and test-up-to-step with pinned upstream values | 5 | 4 | _fill-in_ | P2 | None of the step-scoped run symbols exist in ide/src, so the feature is unstarted, but the substrate it needs is shipped: the traced dry-run, the line-containment fold and the sample picker are all present. Value is capped at parity with a clean workaround, since running the whole handler in the Test Bench already works; difficulty sits at 4 because the stop condition and state dump ride an existing path while the pin mechanism, the redaction reuse and the IDE surface cross the CLI-to-extension seam. | +| 121 | **#328** | `audit-verify` cannot detect a truncated audit tail | 5 | 4 | _fill-in_ | P2 | The operator-runnable half landed, so a compliance job can snapshot and compare an anchor, but the automatic startup check at pipeline/engine.py:860 remains truncation-blind because no [integrity] anchor key exists at config/settings.py:3285-3309. Value 5 because the CLI path is a workable substitute for the automatic one; difficulty 4 because the exact point-in-time seal has to be replaced by a seal-on-stop or monotonic-prefix comparator before the three-file plumb through settings, engine and create_managed_app is worth wiring, and a prefix comparator lands on all three store backends. | +| 122 | **#1030** | Non-cp1252 characters in source are gated one file at a time, so the class keeps recurring | 5 | 4 | _fill-in_ | P2 | The Python half landed with the controls this ledger asks for, and its own scope note leaves 48 .ps1 files against 39 .py on the per-file mechanism the item names as the defect. Value 5 because the surface is scripts/ rather than the engine and each recurrence costs a manual audit; difficulty 4 because the reusable part is the walk and not the exemption model -- the shipped gate recognises a hardened file by matching stdout.reconfigure(, which has no PowerShell analogue -- and the silently-failing decode direction is still unowned. | +| 123 | **#1071** | A UNC-spelled governed root is not de-aliased by rev-parse, so rule 3c allows a config write through it | 5 | 4 | _fill-in_ | P2 | The hole is open and the gate file records it: scripts/hooks/worktree_gate.ps1:1607-1614 states that an extended-length or UNC admin-share spelling normalises to //?/c:/... or //localhost/c$/..., matches no governed root, was verified live on this box, and that closing it means folding both prefixes into Get-ComparablePath, moving rules 3, 3b, 3c and 3d with it. Difficulty 4 because the change itself is a lexical fold inside a five-line function (:164-168) and the cost is the cross-rule test matrix plus a negative control, not a new mechanism. | +| 124 | **#1121** | research an honest pass for ASVS 3.4.6 -- frame-ancestors on every response without weakening the attachment sandbox | 5 | 4 | _fill-in_ | P2 | The scoping is unchanged: frame-ancestors ships only on non-static /ui responses and the attachment response is served default-src 'none'; sandbox, which takes no fallback for frame-ancestors. Value 5 because the item itself records that practical clickjacking exposure on a first deployment would be low, so what is bought is posture plus the small measured hole on the 413 and 500 paths that carry neither header; difficulty 4 because the research must find a construction carrying the directive on every response without weakening either policy that is currently correct, and a global header and a per-route header interact rather than compose. | +| 125 | **#1141** | research an honest pass for ASVS 6.4.5 -- a deadline and a reminder for a credential the engine cannot reach the holder of | 5 | 4 | _fill-in_ | P2 | The gap stands: the 72-hour default at settings.py:1851 is enforced at auth/service.py:786-798 and stated nowhere a holder or issuing administrator would see it, and settings.py:4264 separately records the same field as unreported on the loosening surface. Value 5 is rung 5 -- a real conformance and usability gap with a clean out-of-band workaround, since the administrator issuing the credential can state the deadline. Difficulty 4: attaching the deadline to the reset response, the console one-time page and the create-user hint is a feature across a seam with no store work, ahead of a bounded reading of whether a 72-hour credential needs an automated reminder at all. | +| 126 | **#1147** | research an honest pass for ASVS 7.4.3 -- offering session termination as part of the MFA-change ceremony rather than beside it | 5 | 4 | _fill-in_ | P2 | disable_mfa still offers and revokes nothing, and the post-disable redirect still lands on a page whose only relation to session termination is a link, so the option remains adjacent to the ceremony rather than part of it. The capability ships and is one click away, which caps value; difficulty 4 covers research plus a uniform ceremony across five factor-change paths without cutting the caller's own session mid-flow. | +| 127 | **#1158** | research an honest pass for ASVS 10.2.2 -- whether an id_token iss claim is a real substitute for the RFC 9207 iss parameter | 5 | 4 | _fill-in_ | P2 | The defense is still the id_token iss claim compared to the pinned issuer (oidc/claims.py:227) and the RFC 9207 authorization-response parameter is absent from all four files of the OIDC package, so the unverified equivalence the residual flags is unchanged. Severity is bounded because the back-channel legs pin their own token endpoint per connection, and the likely deliverable is a standards determination plus, if needed, one added parameter check on an existing seam. | +| 128 | **#1163** | research an honest pass for ASVS 11.1.2 -- how a cryptographic inventory can claim ALL without an unmeasured completeness claim | 5 | 4 | _fill-in_ | P2 | The four uninventoried surfaces are inventoried and the unmeasured completeness claim has been replaced with at-least language, so the residual is the missing per-row usage sentences (13 of 65 section-4 table lines carry one) plus the research question about deriving the inventory mechanically. That research is cheaper than filed because crypto_inventory_check.py already walks five stated first-party roots and fails the build on undocumented usage, so a stated-corpus instrument partly exists. | +| 129 | **#1192** | research an honest pass for ASVS 15.2.3 -- getting development subcommands out of the wheel without taking dryrun off the operator's box | 5 | 4 | _fill-in_ | P2 | Clause (a) still binds: messagefoundry/__main__.py registers dryrun (:162), adr-analyze (:225), generate (:350), lens (:375) and import corepoint (:442, :449) as unconditional subparsers on the same entrypoint as serve (:63), while clause (b) stays clean via the sdist only-include at pyproject.toml:21. Value 5 on the item's own severity, low product effect and no added network surface, which makes this shipped-artifact hygiene; difficulty 4 for a research pass that must settle whether an entrypoint gate reads as does-not-include, plus CLI tests if it becomes a build. | +| 130 | **#1256** | the federated binding guards account-continuity but never subject-exclusivity, so two accounts can bind one identity | 5 | 4 | _fill-in_ | P2 | The behavioural half is closed in shipped code -- auth/service.py:1219-1232 refuses a second account binding one (issuer, subject), with the lookup on all three backends and a sequential test at tests/test_auth_oidc_service.py:456 -- so the remainder is only the database constraint that makes the read-then-write at :1219/:1232 atomic, which is worth a 5 as auth hardening with the app guard covering every non-concurrent case. Difficulty drops to 4 because the amendment moved the design half (refuse rather than re-point) into shipped code and comment, leaving a well-precedented seam: all three backends already declare unique indexes (ux_webauthn_label, ux_search_presets_owner_name), so the novel cost is one first-of-kind SQL Server ALTER COLUMN off NVARCHAR(MAX) (store/sqlserver.py:1358; the file contains zero ALTER COLUMN today), a filtered index for SQL Server NULL semantics, and a CI-only concurrent-bind test on the two server backends. | +| 131 | **#1264** | The seat clock fires on cadence but its fanout skips seats, and the tick's own rubric sends every seat to look at the clock | 5 | 4 | _fill-in_ | P2 | Fleet tooling with no deployment axis, but silent under-delivery of wake-ups is self-concealing and cost seats a day of divided investigation, so worth is mid-range. Difficulty 4 because the remainder is undiminished: read the roster selection source, instrument send-side per-firing results, and re-measure, which is the only thing that separates a roster fault from a send fault, and it must be done against files that are not tracked in this repository. | +| 132 | **#1266** | the seat clock's broadcast fanout drops seats per-firing, and no seat can detect it from its own inbox | 5 | 4 | _fill-in_ | DEMAND-GATE | The item's stated first deliverable is untouched: seat-tick.ps1 is outside version control anywhere in the tree (zero hits from git ls-files against 1997 tracked files, and the only repo reference is docs/BACKLOG.md itself), so every downstream fix would still be an unattributable write to a live machine-global control. Value 5 because this is internal coordination tooling with no product or PHI axis, where the one confirmed defect is the seats-unreadable path that drops every seat for a firing and exits 0; difficulty 4 because the concrete fixes are individually small (ConvertFrom-Json -AsHashtable, emitting SUPPRESSED(opt-out) instead of a bare continue, a durable per-firing decision line carrying a rotation answer) but sit behind the version-control precondition and a 2000-character tick-body cap that kills the fleet clock silently. | +| 133 | **#1269** | the seat clock has no external alarm, and the obvious implementation reads healthy at the moment it should fire | 5 | 4 | _fill-in_ | P2 | The serial re-arm chain has no independent observer inside anything this repository ships, so its death stays silent by construction. Value is moderate because this is fleet tooling with no product axis and peer messaging partly substitutes; difficulty reflects that the naive implementation is wrong in two measured ways, so the build must carry tick-identity dedupe, the suppression exclusion, a pinned absolute path, the throttle-age consistency check, and the discriminating test that fires on a fresh file missing the watched seat. | +| 134 | **#1072** | Rule 3c reads TEXT, and these text shapes are still not read: backtick substitution, variable targets, newline and subshell cd, a heredoc body | 5 | 5 | _fill-in_ | P2 | Two fail-opens on a shared disarm-write rule plus two over-denies, all four reproducible from the shipped source, on a maintainer guardrail rather than a product control. Difficulty 5 because the target-token half must be shell-aware per tool payload rather than one regex, with red-first tests for each shape. | +| 135 | **#1110** | research an honest pass for ASVS 2.2.3 -- whether cross-field reasonableness is shippable at all in a code-first engine, or belongs to the feed author | 5 | 5 | _fill-in_ | P2 | The gap is unchanged: consistency.py:11-17 still describes itself as a compose-it-yourself toolkit, its only non-test importer is samples/consistency/validated_adt.py:27, and nothing on the shipped message path calls it. The research question is genuinely open and its most likely output is a negative finding, so worth-if-built is bounded by the clean existing workaround of a Handler composing the primitives; difficulty carries the HL7-general rule-set research plus, if a set exists, a check on the message hot path with tests. | +| 136 | **#1112** | research an honest pass for ASVS 2.3.4 -- a quota that holds across concurrent uploads and across engine shards at once | 5 | 5 | _fill-in_ | P2 | Only the cross-shard limb remains and it is small: settings.py:461-466 records in shipped code that shards over one uploads_dir enforce one budget, so the remainder is an at-most N-1 file overshoot on a subsystem that is OFF on shipped defaults (uploads_dir default None, settings.py:442) and whose cell the owner already ruled holds at partial. Difficulty is the store-row-versus-advisory-lock decision plus a demonstrated two-shard concurrent-writer run. | +| 137 | **#1150** | research an honest pass for ASVS 7.6.1 -- bounding time since the IdP authentication event without forcing a credential prompt every round trip | 5 | 5 | _fill-in_ | P2 | With federation enabled a first deployment could not bound time since the IdP authentication event at all, and the one shipped lever destroys single sign-on, so there is no acceptable workaround. Difficulty 5: an owner ruling plus one setting, a max_age parameter on the existing authorize call and an auth_time comparison at the id_token seam, with a fallback for IdPs that omit the claim. | +| 138 | **#1151** | research an honest pass for ASVS 8.1.1 -- documenting the data-specific access rules where a reader can actually find them | 5 | 5 | _fill-in_ | P2 | The code enforces more than the documentation describes, so there is no product effect and the residual is silent drift rather than exposure, which caps this below the surfaces that would misbehave on a first deployment. Difficulty 5 holds because the deliverable is not prose: the item requires establishing whether a drift gate over data-scoping claims is constructible at all, plus the public-versus-vaulted split the dangling docs/security pointer is a symptom of. | +| 139 | **#1165** | research an honest pass for ASVS 11.2.2 -- crypto agility for the at-rest and integrity core without shipping a downgrade surface | 5 | 5 | _fill-in_ | P2 | A deploying site could not change the at-rest AEAD, audit MAC, KDF or argon2 parameters without a code release, but the additive-registration path is already built and ruled, so the workaround is a release rather than a redesign. Difficulty 5: research plus an ADR recording the ruling, with no migration and no store-backend leg implied. | +| 140 | **#1227** | Console resend POST bypasses the engine's step-up requirement across the CoreHandlers seam | 5 | 5 | _fill-in_ | P2 | The mismatch is byte-present at HEAD, with the console's own comment at :55 recording the no-step-up property as a fact rather than closing it. Difficulty stays 5 because the obstacle is structural -- a body-carrying POST cannot survive the re-auth redirect -- so this needs a chosen shape (stash-and-replay, a body-less confirm step, or a documented bounded exception), not a dependency swap. | +| 141 | **#1280** | ship the threat model, the handler shared-responsibility note and the dependency-handling note with the source | 5 | 5 | _fill-in_ | P2 | Gap stands: none of the three documents is tracked under docs/ at HEAD, and the drift suite still announces itself inert through ThreatModelDocUnenforced (:326, :346), so publishing turns on an existing 34-function gate at no test-writing cost (value 5, an adopter-facing artifact with no deployment axis). Difficulty 5 because the redaction is judgement work under a subtraction rule, not a copy: the item measures 20 plus 21, 35 plus 3, and 1 plus 1 flagged lines across the three files, each published text needs owner sign-off, and the landed text must then satisfy the suite's pinned headings, anchor registries and numeric-parity assertions. | +| 142 | **#78** | Custom message-definition data model + conformance validator; NCPDP codec | 5 | 6 | _money pit_ | DEMAND-GATE | The whole scope is still remainder: NCPDP appears nowhere under messagefoundry/ and the validator's profile argument is still an accepted no-op (parsing/validate.py:48-56). Value 5 because a code-first Handler is a clean workaround and the 75-feed estate port completed on one; difficulty 6 because the remainder after the #1213 codec split is a persisted definition model the code reads plus a report-only validator, spanning parsing, config and the store it is read from, while staying on the correct side of the #26 no-GUI-modeler line. | +| 143 | **#85** | Cloud object-store + generic message-bus destinations | 5 | 6 | _money pit_ | DEMAND-GATE | Re-measured: the transports package carries 25 modules and none is an object-store or message-bus driver, and pyproject.toml names none of boto3, azure, google-cloud, kafka or pika. The connector registry gives an adopter a clean code-first workaround so value stays at parity breadth, and the remainder is the whole scope (four-plus drivers plus four vetted dependencies through the hash-locked lock file, each with credential sourcing and egress allow-listing), which is past the single-connector band 5. | +| 144 | **#99** | AD/gMSA production-deployment hardening — turnkey enterprise (Windows/AD) install (P3, on-trigger) | 5 | 6 | _money pit_ | DEMAND-GATE | The engineering half is on main and the sole residual is provisioning, not code, so the price is the rig rather than the patch. Difficulty 6 rather than the banner's 3 because the banner's 3 measured the turnkey polish that has since shipped, while the remainder needs a domain lab the project does not own. | +| 145 | **#1003** | Validate the lab and discharge the four hardware-gated residuals | 5 | 6 | _money pit_ | DEMAND-GATE | The owner ruled 2026-08-20 that no multi-VM lab exists, so the trigger cannot fire, and the item's own cheapest deliverable is undone: docs/BACKLOG.md:3075 still tells a reader #351's blocker is scheduled to become false. Worth-if-built is breadth over four validation residuals whose own banners read 5, 3, 3 and 4, none of them a shipping-default defect, so 5 rather than a number above every part. Difficulty 6 on the explicit Windows-CI-gated anchor: #320 needs a registered self-hosted WS2025 runner, #99 and #98 a real DC plus AD CS plus gMSA, #351 a real SQL Server. | +| 146 | **#1113** | research an honest pass for ASVS 2.3.5 -- which flows in a PHI engine are high-value enough to demand a second approver, under a default that must not strand a single-operator site | 5 | 6 | _money pit_ | P2 | The code matches the item's substance: the gate ships off, three operations are registered and all three are guarded at api/app.py:2236, :2730 and :2837, and the default approvable set names only two, so no user-administration and no PHI-export flow is approvable in any configuration. Value stays mid-band because the consequence is a narrow opt-in control gap with no first-deployment product effect; difficulty is 6 because an honest pass most likely means widening the registry across API, settings, audit and console while resolving the self-approval availability cost at api/approvals.py:120. | +| 147 | **#1155** | research an honest pass for ASVS 10.1.1 under the owner ruling that a default-off OIDC feature is a partial | 5 | 6 | _money pit_ | P2 | No product effect and the enablement boundary is an owner ruling rather than a defect, but the same question governs at least 10.1.2, 10.2.1, 10.2.2, 10.5.1 and 10.5.4, so answering it once carries more than one cell and holds value at 5. Difficulty stays 6 because the two custody deltas the residual names, an operator-declared cleartext relaxation under ADR 0153 and the ECH sidecar's loopback bearer hop under ADR 0139, are cross-cutting rather than record work, and the honest outcome may still be that no pass exists. | +| 148 | **#1186** | research an honest pass for ASVS 14.2.4 -- doc-to-code fidelity when the measuring document is written as a mirror of the code | 5 | 6 | _money pit_ | P2 | The operator-surprise evidence that carried this item's severity is repaired -- all four named documents now describe the shipped auto-bound and record the previous inversion (CONFIGURATION.md:1529, SECURITY-LOOSENING.md:221-223 and :300, PHI.md:1061-1063) -- so the remainder is the research question plus a binding gate for gate-behaviour and integrity prose, which the item itself doubts is mechanisable. Difficulty stays at 6 for exactly that reason. | +| 149 | **#1195** | research an honest pass for ASVS 15.4.4 -- fair thread access on the shipped SQLite posture, or a measured argument that the existing pools already suffice | 5 | 7 | _money pit_ | P2 | The only cross-subsystem reservation still ships off and is still SQL-Server-only by construction, so on the shipped SQLite posture route and transform CPU and all blocking transport I/O share one default pool with the argon2 semaphore as the sole reservation. Value 5 because connector timeouts bound the wait and the recorded assessor dissent may be right that bounded pools already satisfy the verb, so the practical exposure is a delay rather than starvation; difficulty 7 because any mechanism has to clear ADR 0071's measured NO-GO boundary without regressing throughput, and the operative phrase about a reasonable timeframe is recorded as unassessed, so even the argument route needs a new measurement. | +| 150 | **#1215** | ADR 0161 and `mail-drain.ps1` describe the pre-wiring channel, and the script contradicts itself about markers | 4 | 1 | _fill-in_ | P3 | The inverted marker model is rewritten, leaving two false status claims and one glyph. Value stays 4 because the remainder is the same trap class rather than a milder one: ADR 0161's Status line and its Status section agree with mail-drain.ps1's header that nothing is wired, so a reader who re-reads either document has the error confirmed, and only reading install-coordination.ps1:278-279 or the ADR's own contradicting checklist at :405-408 falsifies it. Difficulty 1 because the remainder is edits to two documentation surfaces with no code and no test. | +| 151 | **#167** | Test Bench metadata seeding | 4 | 2 | _fill-in_ | DEMAND-GATE | The trigger has two clauses and only one is satisfied: SetMeta ships at wiring.py:2866 but no read side exists anywhere -- zero meta_get hits across messagefoundry/ and ide/src/ and no meta accessor on Message -- so a transform that READS metadata cannot be written, and the Test Bench cannot need to exercise one. Worth-if-built is IDE Test Bench DX and the seam is a --meta parameter on route_message (dryrun.py:524) and dry_run (:714) plus the Test Bench CLI channel. | +| 152 | **#171** | Runtime log-verbosity control + in-product log viewer | 4 | 2 | _fill-in_ | DEMAND-GATE | Remainder is console wiring over a JSON API that already answers the live-incident case, so an operator has a working path and the worth is console polish. Difficulty 2: a page builder emitting attributes the JS already registers, two /ui routes on the existing console seam, and a golden-surface update. | +| 153 | **#177** | Effective-permission inspector for a user | 4 | 2 | _fill-in_ | P3 | Remainder is one console pane plus an apiclient wrapper over an endpoint that already resolves the flattened set, so the workaround is a direct API call and the worth is console polish on an RBAC-audit surface. Difficulty 2: an additive pane on the existing user-detail seam plus a golden-route update. | +| 154 | **#1026** | The ASVS 12.1.1 TLS-floor probe silently does not run with the console off, and its own comment names three of its four conditions | 4 | 2 | _fill-in_ | P3 | I attacked the shipped claim on four fronts and it survived the first three. (a) The gate itself: messagefoundry/__main__.py:2130-2145 now refuses on `tls_terminated_upstream and data_class is DataClass.PHI and enforcing and not settings.api.public_origin` -- owner ruling (a), console-independent, sitting immediately above the probe gate at :2157-2161. (b) The comment limb: :2147-2156 now states the fourth condition explicitly and says why it is belt-and-braces rather than a hidden scope narrowing, so the "names three of its four conditions" complaint is discharged. (c) Test coverage that actually covers THIS: tests/test_api_tls.py:1437-1470 has the console-OFF arm, a console-ON arm, and a synthetic-data negative control; I ran them under .venv and got 3 passed. The console-OFF assertion is discriminating rather than coincidental -- it asserts "12.1.1" in stderr, and `grep -n "12\.1\.1" messagefoundry/__main__.py` returns 5 hits of which the only reachable one with `public_origin` unset is the new refusal at :2139 (the probe block at :2157 requires a truthy public_origin to enter). (d) A second inert copy of the gate: `grep -rn probe_tls_floor` over non-test Python returns exactly one call site, :2165, so there is no stale duplicate ladder. Where it does NOT hold up is actionability and documentation. The refusal message this fix added instructs the operator to set `[api].public_origin`, which settings.py:4096-4099 relocates and refuses at load -- the fix hands an operator a remediation that fails. And the landing commit changed no documentation while its own test comment flags the runbook as needing an update, leaving the published config docs describing the setting as console-scoped when it is no longer. That is one limb shipped and a second not, which under this pass's own rule is partly_shipped, not confirmed_shipped. Value 4 on the remainder: the ASVS 12.1.1 control is genuinely no longer inert, so the security gap is closed; what is left is an unactionable string in a hard refusal plus a doc line -- DX-grade, with a clean workaround (CONFIGURATION.md:188 records the relocation). Difficulty 2: a string edit on an existing seam, one assertion, one doc paragraph, no store or backend work. | +| 155 | **#1038** | Rule 3b's remediation names `new.ps1` siblings while most live worktrees are harness-created and nested | 4 | 2 | _fill-in_ | P3 | A developer-guardrail wording question with no product, engine or PHI effect: the printed command works, it is just not the mechanism that made the reader's own worktree. Difficulty 2 because the code half is one hint string feeding the existing emitters plus a test, with the owner decision being the slow part rather than the edit. | +| 156 | **#1039** | `git worktree add --force` also defeats the already-checked-out guard, so "git will refuse this" must be written as conditional | 4 | 2 | _fill-in_ | P3 | The gate's own site is already written as conditional (worktree_gate.ps1:865-879 spells out the three bypass flags and the allowlist reason), so the remainder is one measurement plus the sweep for other deferring sites, and at least two unconditional ones survive (prune-merged.ps1:1271, BACKLOG-CLOSED.md:6675). It ships nothing runnable to an adopter, which caps it at internal-tooling hygiene, and the work is one command plus doc edits. | +| 157 | **#1119** | research an honest pass for ASVS 3.4.1 -- HSTS on all responses from an engine whose shipped bind is plaintext loopback | 4 | 2 | _fill-in_ | P3 | The remainder lands no code: it is a scoping ruling on the honest unit of assessment plus at most a documentation and scorecard change, on an L1 cell with no PHI effect. Difficulty 2 because the coverage construction and the single shared hsts_applies definition already exist. | +| 158 | **#1135** | research an honest pass for ASVS 6.2.11 -- whether publishing the enforced context words is durable enough to count | 4 | 2 | _fill-in_ | P3 | The list a reader needs is published, so the remaining worth is the decay guard the item itself calls the load-bearing half plus the 6.2.11 pointer decision alongside 6.1.2. Difficulty 2: one small test asserting the doc list equals CONTEXT_WORDS, plus a docs pointer edit. | +| 159 | **#1217** | `retry_max_attempts` has no `>=1` floor, and retry-forever has no TOML or env spelling | 4 | 2 | _fill-in_ | P3 | Half 1 ships with its reasoning in the code at settings.py:1078-1086 while models.py:279 stays deliberately unfloored, so the remainder is half 2 only: TOML has no null literal and no validator accepts a word, so the documented retry-forever posture cannot be expressed from connections.toml or the environment at all. Value 4 because a code-first Python connection expresses it today, a clean route on a code-first project; difficulty 2 for a field validator coercing a string spelling to None plus the docs/CONFIGURATION.md:782 delivery catalog row, which also still asserts the pre-floor behaviour and is now false. | +| 160 | **#1235** | a citation to an unallocated backlog number is a trap that arms itself the day the number is issued | 4 | 2 | _fill-in_ | P3 | The rule and both coverage residuals landed, leaving one gap: the detector runs only inside pytest, which is skipped on documentation-only pull requests, and a citation is introduced by editing prose. Wiring it as a workflow step with a paired must-trip and must-not-trip arm is a small additive change on an existing gate seam, and the coverage bound stands regardless since the detector cannot see the private companion repository where the filed instances live. | +| 161 | **#1245** | an administrator password reset re-arms bootstrap retirement, permanently disabling the account it was meant to recover | 4 | 2 | _fill-in_ | P3 | Attacked the shipped claim by re-walking the item's own chain at HEAD rather than trusting names. The gate at auth/service.py no longer tests must_change_password at all: _unclaimed_bootstrap (:598-633) returns None when password_claimed_at is not None, and it is the SINGLE predicate called by both readers -- _retire_superseded_bootstrap (:642) and bootstrap_expiry_warning (:679) -- so the second open-coded copy the item warned about is gone too. Enumerated every must_change_password reference in service.py (13 sites); none is a claimed-ness test. The column is real, not decorative: password_claim_set (store/store.py:1230-1259) emits COALESCE(password_claimed_at, ?) only when must_change_password is False, and an EMPTY term otherwise, so admin_reset_password (:2867, still setting the flag True per ADR 0164) can neither stamp nor clear it. Present on SQLite, Postgres and SQL Server with in-place upgrade backfills. The confirming experiment the item said "does not exist today" now exists and is written in the correct direction -- test_admin_reset_does_not_re_arm_retirement_of_a_claimed_bootstrap asserts the temp credential logs in after a reset on a two-administrator system -- plus sibling tests for the restart and create_local_user triggers, the write-once stamp, the backfill, and the directory-admin regression the fix could have introduced. Docstring and SECURITY.md:61 both corrected. So the availability defect itself is closed; what stops this being confirmed_shipped is the diagnostic limb, which the item states as a separate deliverable and pairs with an explicit "DO NOT CLOSE THIS ON A GREEN RE-RUN". Remainder valued as test instrumentation guarding an ASVS 6.4.6 property (a reset temp that fails to force rotation would be a real defect) but shipping nothing an operator runs; difficulty 2 -- an additive read on an existing seam in one test, no backend work. | +| 162 | **#1249** | SECURITY.md documents an ingest-plane rate limit that no code-first or TOML configuration can reach | 4 | 2 | _fill-in_ | P3 | I attacked the shipped claim limb by limb rather than trusting the lander's amendment. Limbs that DID ship, verified: both keys are keyword-only parameters of `MLLP()` (`config/wiring.py:1028-1029`) and land in the spec settings (`:1155-1156`); the TOML surface reaches them through the same `factory(**settings)` (`config/connections_file.py:286`), tested at `tests/test_mllp_message_pacing.py:199-208` with an unknown-key positive control at `:213-218` that discriminates a `**kwargs` swallow; the off default is preserved and tested (`:188-197`, `transports/mllp.py:116`); the `SECURITY.md` row was reworded rather than left saying "off" as a bare state (`docs/SECURITY.md:1728` now states both surfaces express the keys and why off is ruled), and the doc-to-code guard reads reachability from `inspect.signature(wiring.MLLP)` rather than pinning a preference (`tests/test_security_doc_rate_limits.py:535-585`); `docs/CONNECTIONS.md:289-290` documents both keys. So this is not an anchor-moved false negative -- the primary limb genuinely landed. What refutes "fully shipped" is a surface the item never checked and the fix itself created. The VS Code connection form is not a hand-maintained mirror; it renders whatever `connection schema --json` introspects (`ide/src/connectionSchema.ts` header comment), and section headings come from the own-line comment block preceding a parameter. I executed `build_schema()` in the repo venv and read the emitted `section` for `max_messages_per_second`: the full six-line rationale block, carrying an internal backlog number and the now-false clause that no factory parameter or connections.toml key can populate the setting. That is the same defect class the item was filed over -- an operator-facing statement about the control's reachability that contradicts the code -- relocated to the GUI, which is precisely what the item's "whichever is chosen, the other must not be done by halves" clause forbids. Two smaller residuals are in-repo and checkable: a test docstring still declaring the item open and the choice unmade, and the deferred ledger closure the amendment explicitly left to the ledger seats. Remainder value 4: DX and console-surface polish under the rubric, held at 4 rather than lower because the text an operator would read is false, not merely untidy. Difficulty 2: a comment restructure on an existing introspection seam plus a pinning test and the ledger edits, no runtime behaviour change. Not scored higher on the ten unpaced inbound modules, because the item's own fix menu is (a) expose or (b) delete the row, and pacing other transports was framed as evidence of width, not as the ask. | +| 163 | **#1262** | the docs-only CI short-circuit skips the gates whose subject is documentation | 4 | 2 | _fill-in_ | P3 | The measured defect, membership of the ungated doc-guards lane, is closed: both named absentees are in DOC_GUARDS at ci.yml:236-243, the non-member ruling is recorded inline at :231-235, and the step runs precisely on the docs-only pull requests that skip the suite (:201-202). Value 4 and difficulty 2 on the remainder, which is re-deriving the set in an environment carrying the five CI extras the trace lacked and demonstrating that a Markdown-only pull request carrying a deliberate violation actually goes red, since the lane has never been shown able to fail. | +| 164 | **#1273** | the module that exists to write the C0/DEL test once writes it twice, and logging_setup re-derives it a third time | 4 | 2 | _fill-in_ | P3 | One limb remains and it is a pure refactor: logging_setup.py:66-70 still derives its own table from range(0x20) plus 0x7F and does not import controlchars at all, while the module now states the alphabet once at :67 with both arms reading it. Value 4 because nothing is mis-screened today, two of the three named copies already agree, and at least two further independent derivations sit outside this item's scope (spreadsheet.py:37, soap.py:263), so folding logging_setup in buys the stated cross-module leverage for one file rather than repo-wide. Difficulty 2 for one import, one derivation with an explicit tab subtraction, a test pinning that tab stays excluded, and renaming the acceptance test's scrub_control_chars, which returns zero code hits. | +| 165 | **#1298** | The archive dialog's permanently-discarded warning is not a loss test: a worktree behind main sees landed files as untracked | 4 | 2 | _fill-in_ | P3 | Neither half exists at HEAD: docs/WORKTREES.md carries no subsection stating the warning is an index test rather than a loss test, and scripts/coord/ holds no helper that answers the recoverability question. Value 4 because the cost is developer coordination only, but it is a high-frequency prompt that is wrong in the common case and trains the dismissal habit; difficulty 2 because the check is three git commands over a dirty set and the helper is a small additive script beside the existing coord tooling, with the one real design constraint being that anything it cannot read must count as unrecoverable rather than clean. | +| 166 | **#234** | Steps view projection refreshes on save only | 4 | 3 | _fill-in_ | P3 | Remainder is a UX latency fix on an authoring surface, which is the DX and console polish band, not a functional gap. The debounced channel and the deferral machinery already ship and the false stale-disk premise is already corrected in the code comment at :924-930, so the change is one subscription plus tests; the cost that keeps it above a trivial edit is the ADR 0076 section 5 amendment re-arguing a wholesale-adopted guardrail. | +| 167 | **#343** | Sandbox child stderr is inherited unframed into the engine log stream | 4 | 3 | _fill-in_ | P3 | Both named problems survive: attribution, because stderr=None (sandbox.py:446) leaves child lines indistinguishable from engine lines, and the print() PHI path, because the #1054 filter installed at _sandbox_worker.py:49 is a property of the logging handler only. Value 4 given the same-admin threat model and no product-facing surface; difficulty 3 because the fix is stderr=subprocess.PIPE plus a relay thread mirroring the existing stdout reader, plus a bootstrap redirect of the child's sys.stdout away from the frame fd. | +| 168 | **#351** | SQL Server failover test asserts on a 0.35s wall-clock margin across a real DB round-trip | 4 | 3 | _fill-in_ | DEMAND-GATE | The margin is unchanged in the shipped test and the item's own remedy is parked behind #1003, whose trigger the owner ruled on 2026-08-20 will not fire. Value 4 because the cost is a CI red that misreads as the PR's own defect, with no engine path and no PHI effect; difficulty 3 because the remainder is a one-file test change plus an _acquire latency benchmark that has to run against a real SQL Server rather than the mocked path. | +| 169 | **#1082** | Rule 3c's deny text for a `--global` or `--system` disarm write names the wrong mechanism, and whether the write takes effect is not knowable from the command | 4 | 3 | _fill-in_ | P3 | The wrong sentence is still the only one rule 3c emits, and the rule reaches it for a --global write because the only pre-deny exclusion at :981 is for reads, so a governed cwd resolves and denies with prose that names a mechanism the write does not use. The verdict must stay deny, so the work is a scope-aware wording plus a test that asserts the string rather than the verdict. | +| 170 | **#1116** | research an honest pass for ASVS 3.1.1 -- what the console's browser-support contract actually is, when the document that carried it cannot ship | 4 | 3 | _fill-in_ | P3 | The contract is still sited only in source at _security.py:51, docs/SYSTEM-REQUIREMENTS.md:135 asks for nothing more than a modern browser, and /docs/security/ is still blanket-ignored at .gitignore:170, so the document two earlier pass-moves cited still does not reach a deploying site; the vault scorecard confirms the cell has not moved. The remainder is a verified feature list plus a supported-browser statement in a shipped doc. | +| 171 | **#1123** | research an honest pass for ASVS 3.5.5 -- what a trustworthy postMessage origin check is inside a VS Code webview | 4 | 3 | _fill-in_ | P3 | No PHI and no engine surface, the interface exists only in the VS Code extension, and the platform-limits finding is already written down and test-enforced, which leaves value in the polish band. Difficulty 3 is the vault record plus the SDS-3.7 discharge, and that discharge is not a formality: webviewMessaging.ts claims every panel is served script-src nonce, while stepsView.ts:959 emits a CSP of default-src none plus style-src unsafe-inline with no script-src at all, so the blanket claim has to be checked panel by panel. | +| 172 | **#1124** | research an honest pass for ASVS 3.7.5 -- deriving a supported-browser matrix rather than authoring one that fits | 4 | 3 | _fill-in_ | P3 | SYSTEM-REQUIREMENTS.md:135 still offers only a modern browser, the web-console plan still carries the matrix as open question 1 at 11-web-console.md:446, and SECURITY-DOCS-POLICY.md:5 still withholds the operator-facing copy while the behavioural half ships in _security.py and _html.py, so the antecedent the requirement grades against remains undefined. Value 4 because this is documentation completeness over a working control set with no exposure and no product effect; difficulty 3 because the deliverable is a table derived from the shipped feature floors plus two owner rulings, touching no code, no mypy surface and no test. | +| 173 | **#1129** | research an honest pass for ASVS 5.2.3 -- a literal before-uncompressing check when the declared size is attacker-controlled | 4 | 3 | _fill-in_ | P3 | The permissive Handler-facing default the item re-scoped itself onto is gone: a caller must now pass max_output_bytes or an explicit None, so the severity paragraph no longer describes the code, and the file-count clause is met on the default. What remains is a method question, whether the pinned verb admits incremental per-member enforcement or wants a recorded deviation, with no code change indicated, which prices both numbers in the record-accuracy band. | +| 174 | **#1130** | research an honest pass for ASVS 5.3.2 -- containment for filenames a remote SFTP or FTP server chooses | 4 | 3 | _fill-in_ | P3 | The security-material half shipped: a hostile listing name is refused at the source (remotefile.py:955) before the pattern filter and before the join, so the traversal primitive the item priced is gone and its founding sentence is false in the code. Value 4 on the remainder, which is a census across the file-path-constructing transports plus the record work to move the cell; difficulty 3 because the guard and its both-polarity tests exist to copy and any further sites are the same shape. | +| 175 | **#1156** | research an honest pass for ASVS 10.1.2 when the flow-binding secrets are unconditional but the flow ships off | 4 | 3 | _fill-in_ | P3 | The enablement premise is unchanged (oidc_enabled ships False, now at settings.py:1942 rather than the cited :1880, and :2190-2210 refuses it without ad_enabled) and both live deltas the research must carry are present: the flow cookie's __Host- twin at _auth.py:783 and the hardening opt-out env at :77. Value 4 because the residual reads every binding on the acceptance path as unconditional, leaving a scorecard and method question with no product effect; difficulty 3 because the code half is one conditional in a single console auth module already covered at test_ui_hardening.py:322, and the cleartext branch is decided by the browser's own rule that a __Host- cookie requires Secure, not by engineering. | +| 176 | **#1189** | research an honest pass for ASVS 15.1.4 -- risky-component highlighting when the consolidated table is policy-withheld from the tree | 4 | 3 | _fill-in_ | P3 | The constraint is intact and is stated policy rather than oversight: the blanket rule at .gitignore:170 keeps THREAT-MODEL.md out of every engine checkout (git ls-files docs/security = 0), leaving only the per-library ADR notes. Value 4 because a first deployment's operators would receive no risky-component highlight for the hostile-input parsers or the auth and crypto stack, which is documentation coverage rather than a product defect; difficulty 3 because the deliverable is a public-safe highlight that survives the withholding subtraction plus an owner ruling on who application documentation serves -- prose and a decision, with no code path. | +| 177 | **#1190** | research an honest pass for ASVS 15.1.5 -- which dangerous-functionality classes can be highlighted publicly without becoming the roadmap | 4 | 3 | _fill-in_ | P3 | Only the dynamic-execution class is highlighted in-tree, the other classes the item names appear in no shipped document, and docs/security/ is still ignored at .gitignore:170. The remainder is deciding which classes can be named publicly without the highlight becoming the exploitation guide, then writing them where an adopter receives them. | +| 178 | **#1210** | the connscale FD/RSS peak has no provenance, so a stale-ppid subtree adoption becomes the reported number | 4 | 3 | _fill-in_ | P3 | The misresolution class that produced 35,600 handles at N=12 is prevented by the validated walk, so the residual is auditability of a load-harness gauge with no product or PHI effect. Difficulty 3: cpu_pids already is the covering set on both platforms, and the change threads it into the FD/RSS record, the report field and its tests. | +| 179 | **#1351** | research an honest pass for ASVS 10.4.13 -- whether the OIDC authorization request must leave the browser-visible front channel | 4 | 3 | _fill-in_ | P3 | Scored 2026-08-23 at HEAD, NOT in the 2026-08-20 pass. `auth/oidc/flow.py` builds an authorization-code redirect with PKCE S256 and `response_mode=query`, so the authorization request travels the browser-visible front channel by construction. The question this item exists to settle is whether the pinned requirement demands the request leave that channel -- Pushed Authorization Requests, RFC 9126 -- or whether PKCE on the front channel answers it. Value 4 because the exposure is front-channel parameter visibility and tampering that the identity provider must reject, not a session-forging path; difficulty 3 because the work is reading the pinned text against the flow, and a build would be a PAR client rather than a redesign. | +| 180 | **#1067** | Rule 3c governs by PATH PREFIX, so an independent repo vendored under a governed root inherits its governance | 4 | 4 | _fill-in_ | P3 | Rule 3c still decides governance by an equality-or-slash-prefix test over the resolved git common dir, so an independent clone under a governed root inherits governance, and the deny text still asserts a shared .git that a vendored clone does not have. Value 4 because it is a false deny in developer tooling with no product surface; difficulty 4 because any fix must pin both directions at once -- allow the vendored clone while keeping the nested .claude/worktrees case denied -- and it forces the git-submodule question the item deliberately deferred. | +| 181 | **#1127** | research an honest pass for ASVS 5.1.1 -- a per-feature file inventory that stays true, against a doctrine that treats enumerations as liabilities | 4 | 4 | _fill-in_ | P3 | Both concrete defects are fixed in shipped text: the SCP is enumerated as the fourth file surface and the 16 MiB inflate ceiling the item said appeared nowhere under docs/ is now documented. The remainder is the half the item was actually filed for -- an inventory that stays true as connectors are added -- since the block states it is hand-maintained and the drift test pins tokens rather than deriving the surface list from code (value 4, difficulty 4). | +| 182 | **#1157** | research an honest pass for ASVS 10.2.1 when PKCE and state are unconditional but the code flow ships off | 4 | 4 | _fill-in_ | P3 | The code is not in dispute and I confirmed it: PKCE S256 is hardcoded at auth/oidc/flow.py:215 with no configuration path, and oidc_enabled still ships False at settings.py:1942, so what remains is purely the scorecard-method question. Value 4 because the item states there is no product effect and the researcher is explicitly barred from changing the method, leaving a verdict argument as the deliverable. Difficulty 4: no code lands, but the reading has to be argued from ASVS 5.0.0 front matter and conformance guidance against a standing owner ruling, which is more than a doc edit. | +| 183 | **#1183** | research an honest pass for ASVS 13.3.2 -- least-privilege secret access, starting with whether the recorded verdict is even right | 4 | 4 | _fill-in_ | P3 | The item concedes no direct product effect and is explicitly an assessment-quality gap: the stated basis for the recorded partial is refuted by a sibling cell, so the work buys record accuracy rather than a control. Difficulty is a re-derivation against the pinned verb plus a survey of what the engine could introspect about its own secret-store privileges across Vault, environment-injected passwords and wincred, with no engine code change indicated. | +| 184 | **#1213** | NCPDP codec -- additive tolerant codec on payload-agnostic ingress, split from #78 | 4 | 4 | _fill-in_ | DEMAND-GATE | Pharmacy claims sit outside the HL7, FHIR, X12 and DICOM scope the project has taken on and no feed exists, so it is breadth rather than a gap anyone is blocked by. Difficulty is the shape shipped twice already, a pure tolerant codec under parsing/ riding payload-agnostic ingress behind its own content_type, with no transport registration, store work or backend matrix, which keeps it below the new-connector band. | +| 185 | **#1265** | the warning sign is unsanctioned decoration in 496 places across 80 files | 4 | 4 | _fill-in_ | P3 | Re-censused at HEAD with the ledger counts as the positive control the item demands: 499 occurrences across 81 files, with docs/BACKLOG.md at 121 and BACKLOG-CLOSED.md at 93 both reproducing, so nothing has been swept and the population has drifted up from the filed 496 across 80. Value 4 because nothing is mis-parsed and no behaviour depends on it, leaving a real but consequence-free vocabulary defect; difficulty 4 because each site needs an editorial replacement word rather than a mechanical substitution, the two ledger files must be sliced last to protect the status alphabet backlog_status_check.py genuinely parses, and whether retired ADRs are rewritten at all is still an open decision. | +| 186 | **#237** | Per-argument input modes (static templated dynamic) in the Steps view | 4 | 5 | _fill-in_ | P3 | The row contract is still the binary literal-or-not split at lens.py:286 with no mode concept on either side of the seam, and the ten-way taxonomy step 1 needs is not computed anywhere. Value stays 4 as authoring polish with no runtime effect; difficulty 5 covers a new ast classifier, a row-contract change and a per-argument mode selector in the webview, which is more than exposing an existing field. | +| 187 | **#1057** | Rule 3d has no occupancy signal, so it cannot tell an abandoned worktree from a live one | 4 | 5 | _fill-in_ | P3 | The remedy half shipped and the occupancy half did not, on the installed hook as well as the repo copy, which is exactly what the banner claims. Value 4 because the rule fails closed with no data loss and no security effect, the cost being a correct refusal the caller cannot act on; difficulty 5 because the installed hook lives outside every checkout and cannot dot-source scripts/coord/occupancy.ps1, forcing one of three structural options each carrying a drift or fail-open hazard, and whatever is built must also carry the 36-hour age rule that did the actual vetoing. | +| 188 | **#1115** | research an honest pass for ASVS 2.4.2 -- whether human-timing pacing is meaningful for an engine whose only human surface is the console | 4 | 6 | _money pit_ | P3 | The research half is delivered and the code it was written against is unchanged: the /ui surface charges nothing (zero allow_admin_write references in the web console) and the only pacing is a per-request per-actor budget at config/settings.py:2017-2021, never a flow timer. Value 4 because on a first deployment this is a coverage and calibration gap on an admin surface rather than a data-plane exposure; difficulty 6 because a flow timer spanning login, MFA enrolment and approve-then-decide is a new mechanism across auth service, API and console, and the floor has to come from a measurement the record does not have. | +| 189 | **#1154** | research an honest pass for ASVS 8.3.2 -- directory-sourced authorization changes that lag, fail open, and have neither named mitigation | 4 | 6 | _money pit_ | P3 | The 300 s directory reconciliation interval is unchanged at config/settings.py:1904 and its validator at :2085-2091 constrains only the range, so no alert-on-action-while-unauthorized and no revert exists and the requirement's alternative arm is entirely unbuilt. Value 4 because the engine-owned half is already strong (opaque tokens, per-request identity re-resolution, revoke-on-mutation) and what would be built is alerting and revert over a lag in data the engine does not own; difficulty 6 because both named mitigations need definition before either could be built, and revert has no meaning for delivered clinical messages. | +| 190 | **#1167** | research an honest pass for ASVS 11.2.4 -- constant-time recovery-code verification without turning ten argon2id slots into an amplification target | 4 | 7 | _money pit_ | P3 | The data-dependent early return survives on the shipped MFA path: _verify_second_factor walks the argon2id recovery hashes and returns on the first match, so the number of ~64 MiB verifications is a function of which code was presented. Value 4 because the leak is a wall-clock signal on an already-authenticated second factor rather than a bypass; difficulty 7 because the obvious constant-time loop multiplies a 64 MiB argon2id verification by the slot count on every attempt, converting a timing leak into a memory and CPU amplification target, and the evidentiary half has no precedent in this tree. | +| 191 | **#352** | Consult on enterprise AV coverage for SFTP- and file-connector ingest from outside the domain (ASVS 5.4.3 premise check) | 3 | 1 | _fill-in_ | P3 | The premise the consult exists to test is unchanged: the scan seam ships and the remote-file poller actually reaches it at remotefile.py:1020, so the na on 5.4.3 rests on the enterprise covering outbound-initiated pulls rather than on the product being unable to screen them. The deliverable is a recorded set of answers rather than code. | +| 192 | **#1010** | No licence-header gate exists in any language, and 196 first-party sources carry no SPDX tag | 3 | 2 | _fill-in_ | P3 | I attacked the shipped claim limb by limb rather than trusting the item's own SHIPPED banner. Checker exists AND is the right shape: scripts/quality/licence_header_check.py asserts the VALUE (EXPECTED_IDENTIFIER = "AGPL-3.0-or-later", WRONG reported as its own class), requires the tag to be a comment in the language's own prefix within HEAD_LINES=20, so a header-emitting string literal does not count. I ran it live rather than reading it: whole-tree run exits 0 with "1309 file(s) checked, all declare AGPL-3.0-or-later", so the backfill including the five Apache-2.0 files is done. I proved it can go red by planting both classes in the scratchpad (never in the repo): it exits 1 and names WRONG and MISSING separately. Extension scope matches the item's six languages exactly -- COMMENT_PREFIXES covers py/ps1/sh/ts/js/go, and --list yields 1147 py + 108 ts + 48 ps1 + 4 js + 2 sh, which equals the full tracked count for each of those extensions, so no tree is excluded and no in-scope language is uncovered (.go is 0 because #1011 retired the Go tree). Both callers are wired: .pre-commit-config.yaml:180-184 as a local hook with files: \.(py\|ps1\|sh\|ts\|js\|go)$, and .github/workflows/ci.yml:351-353 inside the required `test` job, deliberately NOT paths-filtered. The negative control ships with the gate in tests/test_licence_header_gate.py (four planted classes plus test_the_real_tracked_tree_is_clean, which enforces the invariant in the ordinary pytest job). Adding commits 4d3a72eb and 587ea4b8. So the "no gate exists / 196 headerless files" gap is genuinely gone. It is not confirmed_shipped because the security record still says otherwise in two places, one of them a deviation register whose own neighbouring entry states that naming a nonexistent control there is the worst place to do it -- and because the ledger closure is untaken, which the item's own banner says is the only protection against a rebuild of merged work on any other checkout. Remainder value 3: nothing runnable ships, but discharging a stale risk acceptance and preventing a duplicate rebuild of a landed quick win is worth more than marginal. Difficulty 2: doc edits plus a three-edit coupled closure that backlog_status_check.py hard-errors on if done partially. | +| 193 | **#1173** | research an honest pass for ASVS 11.5.2 -- an RNG-under-demand claim that survives the IDE extension staying in scope | 3 | 2 | _fill-in_ | P3 | The specific surface the item said held the cell is gone: cspNonce.ts draws 144 bits from node:crypto and a shipped test pins that no production source uses Math.random for a nonce, so the scope-ruling temptation the item exists to refuse no longer has to be taken. The remainder is the item's evidence question plus a verdict edit in a record this repo does not hold (value 3, difficulty 2). | +| 194 | **#1228** | Mail expiry is silent in both directions, so a swept message is indistinguishable from one never sent | 3 | 2 | _fill-in_ | P3 | I tried to prove the gap stands and mostly failed -- the fix landed at HEAD~2 in 14c4db18 (PR #460, 2026-08-20), and the item's banner is simply stale. RECIPIENT NAMING limb: SHIPPED. scripts/hooks/mail-drain.ps1:578 declares $expiredIds, :820 fills it only on a WON claim, and :1005-1008 appends "EXPIRED AND NEVER SHOWN ... ... They are in box/$key/expired/". $expired is inside the $anything presence test at :1056, so a drain that sweeps and shows nothing still emits. SENDER limb: SHIPPED. :828-836 writes a receipt with disposition 'expired-unshown' and a deliberately EMPTY observedUtc, and a write failure is counted ($expiredReceiptFailures, :843) and reported (:1010-1012) rather than swallowed. Receipts live at mail-root `receipts/`, not inside box/$key/ (scripts/coord/mail.ps1:594), so the sender can read it without touching the recipient's box -- the item's exact acceptance wording. The item's "how to prove a fix" is met literally: tests/test_session_mail.py:1624 asserts BOTH halves against ONE drain run with a fresh message as a negative control, and :1676 pins the no-expiry path. I checked the symbols are CALLED, not merely present, and that mail.ps1:481-487 replaced the wrong "-Status" pointer with the receipt path. What defeats confirmed_shipped is the third limb the item lists under "WHAT SURVIVES ... and it is still worth building": the signal is transient. I verified by grep that `Sub 'expired'` occurs zero times in scripts/coord/mail.ps1, so neither reporting mode can answer "what did I lose" after the drain's turn ends. The scope ambiguity is real and I state it rather than hide it: the item's narrowing sentence names only two limbs ("sender-side signal plus naming the swept message in the recipient's count"), which are both done, while its bullet list and candidate (b) name this third one. Per the pass rule, an unbuilt limb makes this partly_shipped, not confirmed_shipped. Remainder is a small additive change on an existing seam -- one more Get-BoxMessages call, a column, a named block, two doc edits -- against a suite that already covers the channel, hence difficulty 2; value 3 because the two directions that made the loss invisible are closed and the residual is internal coordination-tooling durability plus a doc enumeration that is now incomplete. | +| 195 | **#1267** | the tick rubric asks only about cadence, so it cannot express a fanout fault and its change-test conceals a stoppage | 3 | 2 | _fill-in_ | P3 | The artefact is not in the repo at all, so I went to the live file the item names: %USERPROFILE%\.claude\mefor-usage\seat-tick.ps1 (39113 bytes, mtime 2026-08-20 13:25, plain ASCII, verified with `file` + xxd so a null grep is not an encoding artifact). What I found refutes "fully shipped" in both directions. The shipped part is real but incidental: a comment at :277-285 records a 2026-08-20 owner-instructed REWRITE that deleted the entire diagnostic rubric -- "QUANTISED", "FANOUT", "DELIVERY fault", "Act only if something actually changed", "Waking is not a reason to do work" all return 0 hits, and the body is now a 357-char bare wake-up (seat-tick.bodylen reads 357). So the offending sentences are gone. But they were removed for an unrelated reason (do not spend a turn analysing the tick), and the item's actual deliverables did not land. The replacement question is absent; the tick still carries a pending/change test ("Nothing pending -> do nothing") of the same shape the item says is compatible with total stoppage; and the Verdict's explicit second half, a check that the prose cannot silently revert, does not exist -- the three guards at :313, :324 and :347-357 assert the opening marker, the length cap, and length CHANGE, none of which can see a semantic revert. I also checked for relocation rather than assuming deletion: grep -rli "quantised" over the checkout hits only docs/BACKLOG.md, harness/load/connscale/probe.py (unrelated) and a pygments file in .venv, and "FANOUT SKIP" appears in the repo only inside #1267/#1266 prose, so the rubric was not moved into roles/ or docs/. The shared prerequisite is independently disproven from the state files themselves rather than from the item's claim about them. Item is still open in the live ledger (banner is the open numeral at docs/BACKLOG.md:9976) and absent from BACKLOG-CLOSED.md. Value drops from the filed 5 because the two worst sentences are genuinely gone and severity is none with no deployment axis; difficulty stays low and if anything fell -- the near-miss that dominated this item was the 2000-char cap with 40 chars of headroom, and headroom is now 1643, so the fleet-killing edit hazard is much reduced. Not cannot_determine: I could read the evidence, it just lives outside version control. | +| 196 | **#98** | Kerberos SSO channel-binding (EPA) opt-in + acceptor-enforcement spike (P3, on-trigger) | 3 | 3 | _fill-in_ | DEMAND-GATE | The acceptors are still built with no bindings and no CBT knob exists anywhere in the engine, not merely under auth/, so the remainder is the spike plus at most one conditional per-mode flag on the in-process-TLS path. It stays a niche interop knob that is structurally void behind a TLS-terminating proxy, and the trigger has not fired: no deployment wants EPA and no domain-joined lab box exists to answer the acceptor-enforcement question. | +| 197 | **#148** | X12 TA1 interchange-acknowledgement generation | 3 | 3 | _fill-in_ | DEMAND-GATE | The pyx12 walk yields a conforming 997/999 for free, so only a contract mandating interchange-level accept or reject reaches for TA1, which is a niche interop knob. Difficulty 3 rather than 2 because interchange.py splits on ISA and IEA but validates neither the IEA control number nor the group count, so the build is envelope integrity plus the A/E/R note-code table plus a TA1 envelope emitter, not one function beside the splitter. | +| 198 | **#159** | TCP stream-until-close (no-framing) mode | 3 | 3 | _fill-in_ | DEMAND-GATE | Gap stands: FrameCodec.__post_init__ still rejects start == end (framing.py:63) and codec_for still demands a preset or both delimiter bytes (framing.py:167-170), and tcp.py:68-77 has no path around the codec, so close-framing is inexpressible. A niche transport-breadth knob (value 3) whose remainder is a read-to-EOF branch on the source plus write-then-close on the destination, i.e. one new setting on one connector (difficulty 3). | +| 199 | **#183** | SOAP MTOM/XOP binary packaging | 3 | 3 | _fill-in_ | DEMAND-GATE | No MTOM, XOP or multipart/related handling exists anywhere in the SOAP destination, so binary still goes out base64-inline in the string-concatenated envelope. The work is confined to that one connector, and the trigger, a SOAP document-exchange partner requiring MTOM that a migration depends on, has not fired. | +| 200 | **#320** | windows-2025 is the slowest CI leg (1.8x-3.5x), but that does not explain the 60/s failures | 3 | 3 | _fill-in_ | P3 | Diagnosis only: the CI symptom was absorbed by #115's widened stranding budget, nothing was lost, and the record-correction half is in the tree at report.py:596-606. What is left is a single measurement under concurrent load driven from an existing dispatch-only workflow, so both worth and cost stay small. | +| 201 | **#1120** | research an honest pass for ASVS 3.4.4 -- proving nosniff on every response, not on the responses someone remembered | 3 | 3 | _fill-in_ | P3 | I attacked the shipped claim first and it survives in part -- this is not an anchor-moved false negative. The item's cited anchor app.py:1257 is genuinely gone because the header literals moved to the new api/header_floor.py, and commit c49a8a7b (PR 422, confirmed ancestor of HEAD) built exactly the construction the item asked research to find: a pure-ASGI setdefault floor registered outermost, plus a hand-set baseline on the unhandled 500 because ServerErrorMiddleware sits outside user_middleware by construction. Both of the item's originally measured escape paths are closed and tested, with a vacuity guard and negative controls in tests/test_api_security_header_floor.py. So limbs A (construction), B (can Starlette ordering reach pre-middleware rejections) and C (an admissible measurement) landed. Then I attacked the other direction, because a research item closes on its verdict, not on a commit. The record of record holds the cell at partial as of a re-verification dated 2026-08-17 whose reviewer line names PR 422 itself -- so the assessment was made WITH the floor in the tree, not before it. Its arithmetic: four response-emitter classes derived structurally from create_app's registration order and the scope-type guards, two covered, two not. I verified both uncovered classes against HEAD rather than trusting the record: `websocket.http.response` returns 0 occurrences under messagefoundry/ against 7 `websocket.close` sites as positive control, and header_floor.py:91-93 still returns early on non-http scopes. I also confirmed none of the cell's own re-score triggers has fired since that measurement -- app.py:5317 still registers the floor last, BASELINE_SECURITY_HEADERS still carries the nosniff pair, and git log b8c22cc8..HEAD shows no change to header_floor.py or client_networks.py. Two failure modes I deliberately checked against. A similarly-named symbol is not the fix: I read header_floor.py end to end and traced its registration, rather than counting the module's existence as closure. And a moved anchor is not an open gap: I confirmed the setdefault relocated rather than vanished. The distinction that decides this item is that the shipped work is the honest construction the item wanted and still does not reach the honest pass the item is named for, which is partly_shipped, not confirmed_shipped. Value 3 on the remainder rather than the filed 4: the practically important half closed, since the JSON error bodies that motivated the filing now carry the header, and a websocket 403 has no body a browser sniffs. It is not 1 or 2 because real runnable work is left -- the denial-response emitter plus the guard widening -- and because a shipped security module currently justifies its scope bound on a premise the engine's own adjacent comment contradicts, which the project treats as a defect class in its own right. Difficulty 3: a small additive change on an existing seam, but it needs an ASGI extension-capability check with a fallback to the bare close, a middleware guard widened without weakening the network gate that sits inside it, and tests across all three. No migration cost is priced in -- nothing is deployed, so there is nothing to break. One scoping note on sourcing: the verdict itself lives in the maintainer-internal scorecard, which I could reach because the vault clone is checked out beside this repository. I have deliberately not reproduced cell content beyond the fact that 3.4.4 holds at partial and the emitter-class count, since coverage detail over a published catalogue discloses the complement by subtraction. | +| 202 | **#1161** | research an honest pass for ASVS 10.5.4 when the ID Token audience check is unconditional but the relying party ships off | 3 | 3 | _fill-in_ | P3 | Both halves hold at HEAD: the audience check runs unconditionally in auth/oidc/claims.py:230-240 and oidc_enabled is still False by default, now at config/settings.py:1942 rather than the cited :1880, which is itself an instance of the anchor drift the item warns about. Value 3 because the code is correct and the deliverable is a record correction under a standing owner ruling with no product effect on any default; difficulty 3 because no code change is even available -- enabling federation by default is ruled out by the item -- so the remainder is a re-anchor by token plus an argued position and an owner call. | +| 203 | **#1254** | a required check is named for its subject, not its assertion, so a passing suite reds under a label that says the tests failed | 3 | 3 | _fill-in_ | P3 | The measured cost, a seat reading the label and forming the wrong hypothesis, is now met at the point of failure by the gate's own first line, so what is left is the optional and dangerous half. Difficulty 3 prices a decision between leaving it, splitting the margin gate into its own non-required check, and the atomic rename across the workflow, required-contexts.txt, the pinned count and branch protection, which the item correctly flags can wedge the repo if ordered wrong. | +| 204 | **#83** | Rich file-output disposition + FTPS / SFTP variants | 3 | 4 | _fill-in_ | DEMAND-GATE | Both halves stand at HEAD: FileDestination's constructor enumerates its settings at file.py:204-225 and none of append, dated-subfolder archive or header/trailer framing is among them, and remotefile.py builds explicit FTP_TLS at :291 with no implicit-mode, passive-toggle or keyboard-interactive path anywhere in the file. Niche per-driver interop that no shipped feed needs. | +| 205 | **#110** | DICOM Study/Series Instance UID de-duplication on the C-STORE SCP | 3 | 4 | _fill-in_ | DEMAND-GATE | No Study or Series UID de-duplication exists on the C-STORE SCP: transports/dicom.py commits per object at _on_c_store (:273) and _commit (:368) with no seen-UID state, and grep over that module returns no StudyInstanceUID or SeriesInstanceUID reference at all, while parsing/dicom/peek.py:105-106 exposes both UIDs to a Router that cannot hold cross-message state. Niche DICOM-only study collapse; the remainder is a durable connector-side seen-UID ledger on the processed_files precedent (store/base.py:845, prune_processed_files at :858) plus a FILTERED disposition on the suppressed objects, across three backends. | +| 206 | **#178** | SFTP cipher / KEX / MAC allow-lists | 3 | 4 | _fill-in_ | DEMAND-GATE | Only a FIPS-restricted or otherwise pinned partner endpoint reaches for this knob and nothing on shipped defaults is wrong without it, which holds value at the niche-interop band. Difficulty 4 rather than 3 because the item makes a CI leg carrying the sftp extra mandatory in the same change (no leg installs paramiko today), and the preferred-ordering clause of the Scope must be set on the Transport before negotiation, which restructures _make_client rather than adding a kwarg. | +| 207 | **#1232** | `search_presets.owner` holds an `Identity.user_id` after #1225, so the column name is now misleading | 3 | 4 | _fill-in_ | P3 | The column is still named owner and is still the key in every preset path (store.py:1743, :8053), with 128 preset references across the four store modules. Value 3 because nothing behaves differently and the corrected comment already states which form the column holds, so the cost of leaving it is a reader's wrong inference; difficulty 4 because the rename is a cross-backend change tested on SQLite, PostgreSQL and SQL Server, which is the rubric's own rung-4 text. | +| 208 | **#1017** | worktree_gate rule 3d has no ownership signal, so it denies a session removing a worktree it created itself | 3 | 5 | _fill-in_ | P3 | The harm the item priced -- a deny asserting as fact that the target belongs to another session -- is closed: the self case has accurate text and the other branch declares that it has no authorship signal. Value 3 because what remains is an honestly-worded refusal with working fallbacks and no product surface, plus the branch-deletion prose error; difficulty 5 because a real ownership signal still means recording creation provenance at worktree add time, a verb the rule does not even match today, and the payload carries no session identity (0 hits). | +| 209 | **#1145** | research an honest pass for ASVS 7.1.3 -- federated session-lifetime coordination when the Kerberos leg exposes no ticket lifetime | 3 | 5 | _fill-in_ | P3 | Two of three mechanisms ship and the Kerberos limb is untouched: no ticket end time is read anywhere in ldap.py and ADR 0079 still records the cell as accepted-not-built with its promotion trigger unfired. Value is low because the cell carries a signed risk acceptance with a 2027-01-14 review and there is no product effect; difficulty stays mid-band because the honest answer depends on measuring whether any non-SSPI ticket-lifetime source exists on the targeted platforms without forking pyspnego, which the project has ruled out. | +| 210 | **#1153** | research an honest pass for ASVS 8.2.4 -- adaptive controls inside a live session on a single-host loopback deployment | 3 | 6 | _money pit_ | P3 | The one in-session contextual signal still ships off and is documented in the shipped code as never changing an authorization decision, and the allowed-networks list still ships empty with a recorded inert-behind-NAT limit, so the existing-session half of the requirement rests on nothing at the shipped bind. Value 3 because no signal changes an authorization outcome in any configuration, so what is bought is a cell rather than a closed exposure; difficulty 6 because the honest answer needs an owner boundary decision first and the four attribute classes the verb names are largely unavailable to an on-premises single-host engine, with time-of-day gating on a 24-hour clinical service actively harmful. | +| 211 | **#1174** | research an honest pass for ASVS 11.7.2 -- encrypt-after-use for plaintext that lives in immutable CPython objects | 3 | 6 | _money pit_ | P3 | Unchanged in code: only the cipher's mutable buffers are locked and zeroized, and the caller's plaintext str plus the bytes cryptography returns are still immutable with no wipe hook. Value is low because exploitation requires memory access to the engine process, making it defence in depth; difficulty is 6 because the item forbids narrowing the whole-message Handler contract, so the buildable arc is a field-scoped view or decrypt-on-access accessor across store crypto and the transform path, which is cross-cutting but touches neither the stage handoff nor the ACK contract. | +| 212 | **#155** | Server-to-server migration runbook | 2 | 1 | _fill-in_ | DEMAND-GATE | Every constituent step still ships documented and separately in docs/EARLY-ADOPTER-GUIDE.md (section 4 install, 10 backup/restore/DR, 13 upgrade and rollback, 16 decommissioning) and no page stitches them end to end -- the phrase occurs in docs/ only inside BACKLOG.md. Value 2 because the capability is complete and following the sections in order is a clean if awkward workaround; difficulty 1 because it is one new doc and no code, the care being the quiesce and drain ordering. | +| 213 | **#1061** | Rule 3c fails open when the primary is named by a relative path, disarming every worktree's commit gates | 2 | 1 | _fill-in_ | P3 | Nothing buildable remains under this item's own title: the relative-path fail-open denies on main in the two-step shape it prescribed (scripts/hooks/worktree_gate.ps1:1031 roots the target via Get-FullPathRaw at :147, :1032-1050 is the fail-closed deny, :1054 resolves the common dir against the rooted target), the fail-closed branch is tested at tests/test_worktree_gate_control_plane.py:332, and the installed hook is byte-identical to the repo copy at sha256 61a63c95, refuting the banner's still-commit-a67838d2 sentence. Value 2 and difficulty 1 because what is owed is a ledger correction plus repairing #1071, which asserts the gate asks rev-parse --absolute-git-dir when that string returns zero hits against a git-common-dir control of four. | +| 214 | **#1202** | the vault ASVS gate runs a verifier this repo owns, on a bare interpreter, and nothing here checked it would run | 2 | 1 | _fill-in_ | P3 | I split the item into the limbs it names and checked each against HEAD rather than against its anchors. Limb 1, the verifier exists at the literal path both vault workflows hardcode: scripts/asvs/scorecard.py is present and tests/test_asvs_verifier_vault_contract.py::test_the_verifier_is_at_the_path_the_vault_hardcodes asserts it with VERIFIER_REL hardcoded to the same string. Limb 2, the stdlib-only contract enforced by an ast scan that sees DEFERRED imports (the load-bearing half per the item): import_roots uses ast.walk, not a module-level scan, and it also traps relative imports as "" since scripts/asvs has no __init__.py. Limb 3, the bare-interpreter smoke run: test_the_verifier_runs_as_a_bare_script_with_nothing_installed shells `python -I -S --help` from tmp_path, which reproduces the vault's sys.path[0]=scripts/asvs invocation, and it asserts on stdout containing "--scorecard" rather than on the exit code alone. Limb 4, the detector is driven: eight parametrized mutation cases (httpx, fastapi, yaml, a function-body pydantic, a branch-deferred requests, two messagefoundry first-party shapes, a relative import) plus a negative control asserting 6 roots and zero non-stdlib. I could not run pytest here (no pydantic in this checkout, so tests/conftest.py fails to import), so I did not take the test file's existence as proof. Instead I executed the module's own main() -- `python tests/test_asvs_verifier_vault_contract.py` printed 19 import statements across 2614 lines, all STDLIB, rc=0 -- and independently ran `python -I -S scripts/asvs/scorecard.py --help`, rc=0. I then re-implemented the ast scan myself over all four MIRRORED_TOOLS as a control: scorecard.py, asvs_tally_lint.py, asvs_residual_lint.py, prove_report.py all return zero non-stdlib roots, so the guard's green is a real property of the tree and not a scanner that stopped seeing things. The one thing I found that the claim glosses over is not a gap in the fix but a drift in its description: the guard was moved into the tooling partition after it landed, so the item's "the required `test` leg runs this module" is false at HEAD even though the merge gate still covers it via `CI gate`. The third vault claim -- that recorded evidence anchors still resolve -- is explicitly out of scope in the item and needs the private MessageFoundry-vault clone I cannot read from here; that is a stated non-goal, not an unbuilt limb, so it does not make this partly_shipped. Scoring the remainder: a banner flip and two stale sentences ship nothing runnable, hence value 2 rather than 1 only because something genuinely is left, and difficulty 1 as a doc edit. | +| 215 | **#1221** | A doc lint crashes when printing a hit that contains a glyph, so it is unreadable exactly when it fires | 2 | 1 | _fill-in_ | P3 | Enumerated three limbs from the item's own text and checked each: (a) decide harden-stream vs treat-as-corpus-problem, (b) implement it in the lint, (c) prove it ON THE FAILURE PATH by making the lint FIRE on a glyph-carrying document. All three landed in f298630f (PR #346, 2026-08-12), bundled under an unrelated #1220 commit title, which is why a title-level search misses it. I refused to accept the presence of a function named `_harden_stdout` as proof and ran the control in BOTH directions from a scratchpad copy, editing nothing in the repo. Positive: a temp doc carrying a synthetic AGAINST_TOTAL tally and an arrow glyph, under PYTHONIOENCODING=cp1252 exits rc=1 and prints the path, line and idiom label for that hit -- legible, no traceback. Negative control: the same script with only the single line `_harden_stdout()` neutered dies with `UnicodeEncodeError: 'charmap' codec can't encode character '->' in position 69`, raised from `print(f" {h.path}:{h.line}: ...")` at line 372 -- the item's exact failure site and exact character. So the fix is load-bearing rather than decorative, and the defect is genuinely gone. The test limb is real coverage, not a similarly-named test: tests/test_asvs_tally_lint.py:317 drives the lint as a SUBPROCESS with PYTHONIOENCODING=cp1252 (deliberately, because pytest's capture wrapper is one of the objects the hardening guards against), then asserts no UnicodeEncodeError, rc==1, and that the path/line/idiom survive. That is the item's own stated proof bar. I also checked the anchor-moved failure mode in reverse: the item's cited `:245` and `:63-64` line numbers have shifted (the rglob is now :246, the BACKLOG reference now :64-65), but the mechanism is present and fixed at those moved lines, so the stale anchor is not evidence of anything. Remaining doubt is confined to which reconfigure idiom was chosen: the item's option A said "forces UTF-8 on its own stdout" while citing messagefoundry/__main__.py as the proven in-tree exemplar, and __main__.py:55 itself uses bare `errors="replace"` -- so the shipped fix matches the exemplar the item named, and I do not score the difference as an open limb. | +| 216 | **#1253** | one control-char predicate written out seven times across six files | 2 | 1 | _fill-in_ | P3 | I attacked the shipped claim on the two limbs that can fail independently -- consolidation and adoption -- and could not break either. Consolidation: git grep for the predicate across messagefoundry/ returns it in controlchars.py only, once as implementation (controlchars.py:67, inside a single _is_control_char) and once in the module docstring naming what it replaced; parsing/sniff.py:179 is the byte-wise allowlisted variant the item explicitly excluded and transports/rest.py:110 is the docstring prose it excluded, so both exclusions are honoured rather than folded in. Adoption: I enumerated importers rather than assuming them and got exactly the six files the item names, with seven call sites matching its own table -- config/codeset_edit.py:43/:306, config/impact.py:47/:632, transports/dicomweb.py:48/:143, transports/fhir.py:50/:179 and :202, transports/remotefile.py:67/:122 (all has_control_char), transports/rest.py:57/:112 (strip_control_chars). The reject/strip split the item warned must not be simplified is preserved: rest.py is the only strip site. tests/test_controlchars.py covers the module directly and pins the alphabet, including that chr(0x80) is deliberately NOT caught. So the seven-across-six count this item was filed over is genuinely retired, and calling it still_open would be the inverse error. What is NOT done is the closure the item's own dispatcher paragraph specifies as three coupled edits, and the recent ledger commits show the omission is real rather than merely pending: 3c1c1136 closed #1241 and #1271 as landed-by-content and 75e20d43 closed three more, while #1253 was left open. That is a doc edit, not a build. The eighth-spelling question is NOT this item's remainder: messagefoundry/logging_setup.py:70 restates the same code points as a translation table, but that is the filed subject of #1273 (docs/BACKLOG.md:10259), which also drove the collapse of controlchars.py's own two internal copies -- attributing it here would double-count. | +| 217 | **#1257** | an exception after `engine.start()` unwinds nothing, so a startup refusal hangs the process instead of exiting | 2 | 1 | _fill-in_ | P3 | Enumerated the item's limbs: scope option (1) fix the partial-startup teardown so a raise between engine.start() and the yield unwinds; option (2) was explicitly the inferior alternative and option (3) was recorded as REJECTED, so neither is owed. The item's proof standard is its own: raise in the post-engine span and assert the PROCESS exits, timed and read directly, not that SystemExit is raised. BUILD STATE. The try now opens at api/app.py:5838, one line above `await engine.start()` at :5839, and its finally at :6033 stops the upload-retention runner, cancels reconciler/reaper/bootstrap_reminder, calls engine.stop() (which ends in store.close(), pipeline/engine.py:1882) and closes both notifiers. The five names the teardown touches are hoisted to :5828-5832 with a comment attributing the hoist to #1257 and recording that removing one brought the hang straight back via UnboundLocalError inside the teardown -- so the known second-order trap was measured, not assumed. tests/test_lifespan_startup_unwinds.py exists and passes here (1 passed, 1.49s); it asserts termination under a 30s bound sized below the suite watchdog, carries three positive controls that the child actually reached uvicorn and refused, and pins the no-masking half on the absence of the CPython chaining banner rather than on the presence of the error string -- which the file itself records as non-discriminating. I tried to refute it four ways rather than trusting the test. Injecting a raise (a) at the end of a real engine.start(), (b) at the very start of engine.start() so stop() runs against a never-started engine, and (c) at auth.initialize() deep in the span, each produced uvicorn's correct refusal, a normal return from the child, process exit in about 1.2-1.3s, and NO "During handling of the above exception" chain. (d) I also probed the PRE-engine window the item asserts is already covered (load_config at :5816 and build_coordinator at :5734, both outside the try, both leaving the store open): three runs exited in ~1.2s, so even the unguarded pre-engine window does not reproduce the hang here -- it leaks the store and prints an aiosqlite worker traceback, but it terminates, so there is no second live limb to raise. The anchors in the item prose have moved (:5731 is now :5839) but the fix landed, on PR #394, at 4a8f062d. | +| 218 | **#1272** | the shell-syntax harness resolves whichever bash PATH orders first, and reports a harness failure as 160 content failures | 2 | 1 | _fill-in_ | P3 | The underlying defect is real and unfixed (tests/test_workflow_shell_syntax.py:110, tests/test_dependabot_automerge_guardrails.py:145, tests/test_installed_coord_hooks.py:666 all still use unpinned discovery, and the third resolves sh first), but #1216 is the surviving number for it and its amendment already absorbs this item's unique content, naming the three modules and the 127-versus-2 discrimination layer. The remainder specific to THIS number is closing it with a pointer, a ledger edit. | +| 219 | **#135** | Configurable statistics push / refresh interval | 2 | 2 | _fill-in_ | DEMAND-GATE | The push loop is still a hardcoded await asyncio.sleep(1.0) at api/app.py:5136 between the send_json and the revalidation check, and settings.py carries ws_allowed_origins at :752 with no stats-interval sibling anywhere, so the gap stands whole. Value 2 because a fixed 1s cadence serves live monitoring and there is no interop dimension; difficulty 2 for one validated settings field on an existing seam read by the loop that already exists. | +| 220 | **#173** | Segment/segment-group subtree-copy helper | 2 | 2 | _fill-in_ | DEMAND-GATE | Gap stands and stays thin: message.py exposes segments():193, add_segment():377, groups():470 and a whole-message copy():508, but no cross-message subtree copy exists anywhere in parsing/, so the item's own reading that the hard part is already done holds (value 2). The remainder is one additive method on an existing seam whose only subtlety is re-encoding across two messages' MSH separators (difficulty 2). | +| 221 | **#1019** | install-selfheal.ps1 has no installed-vs-source payload-parity instrument, and it wires the most privileged hook in the estate | 2 | 2 | _fill-in_ | P3 | The detection gap the item was filed for is closed from the pytest side, and the item's own text rules the installer-side readout unnecessary because install-gate.ps1 -Status refuses in-session too, so plain-terminal-only is the precedent rather than a gap. That puts the remainder squarely on the rubric's rung 2, marginal and already substantially covered, at difficulty 2 for a small additive change to one PowerShell installer with two worked examples to copy. | +| 222 | **#1020** | The first-run bootstrap Administrator is created with no email address, and the PHI notification gate cannot see it | 2 | 2 | _fill-in_ | P3 | The gate this item exists for is SHIPPED and wired: `_assert_security_notice_is_deliverable` (api/app.py:5322) runs in the lifespan at :5959 right after `bootstrap = await auth.initialize()` and refuses a PHI enforce instance when no enabled Administrator carries an address, with a lifespan wiring test and a warn-side positive control in tests/test_security_notice_deliverability.py; the uvicorn non-closure rider is discharged by tests/test_lifespan_startup_unwinds.py, which patches that gate's own call site, runs uvicorn in a subprocess and asserts the process exits. What is left is record repair -- ADR 0167 still reads Status Proposed with the false line that the consuming gate is not yet written, its filename asserts an early-lifespan placement that was not built, docs/DEPLOYMENT.md carries no exit-3 line, and has_notifiable_admin has zero non-test call sites. Value 2 and difficulty 2 because a false ADR record plus an orphaned predicate ship nothing runnable. | +| 223 | **#1160** | research an honest pass for ASVS 10.5.1 when the ID Token nonce check is unconditional but the relying party ships off | 2 | 2 | _fill-in_ | P3 | The control is complete and unguarded in the shipped code, so the cell is already substantially covered and only enablement holds it short. The item forbids both engine changes available to it, flipping oidc_enabled and adding a refuse-to-boot gate, and directs the shared optional-feature question to be answered once across all six siblings, so this item's own remainder is applying that ruling and recording the outcome, which ships nothing runnable and costs a record-grade edit. | +| 224 | **#1162** | research an honest pass for ASVS 11.1.1 -- a key-management policy covering all six keys, and an oversharing bound that does not fight escrow | 2 | 2 | _fill-in_ | P3 | The one-of-six scope gap and the missing oversharing bound are both answered in the shipped document, and the answer took the honest route the item demanded rather than re-declaring the scope. What is left is re-verifying the cell against that text and rewriting its residual, which is a verifier run plus a vault edit rather than repo work. | +| 225 | **#1176** | research an honest pass for ASVS 12.1.5 -- whether ECH is reachable at all before CPython exposes an API | 2 | 2 | _fill-in_ | P3 | Re-measured on the pinned runtime: an SSLContext exposes no ECH attribute, the routing half is unreachable from either authoring surface, and the Go sidecar the record leans on was retired from the tree, so the cannot-honestly-reach-pass finding is better supported now than when filed. Difficulty 2 because the deliverable is a recorded finding whose external blockers are confirmable in one probe and two greps. | +| 226 | **#1214** | The threat-model numeric-parity gate compares live constants to a hardcoded transcription, not to the document it names | 2 | 2 | _fill-in_ | P3 | The published defect, a failure message naming an artefact the check never opened, is fixed in place with its reasoning in the docstring, and two guards were added past the filing. The remainder is a one-identifier rename or the document-binding alternative the Scope explicitly makes optional, which ships nothing runnable either way, so value 2 and a small edit on an existing test module. | +| 227 | **#1224** | The `upload.prune` audit row names the file's owner as the actor of an automated sweep | 2 | 2 | _fill-in_ | P3 | Attacked the shipped claim at both cited sites and it survived on the code limb. Site 1 (request-path opportunistic sweep) is now messagefoundry/api/app.py:3918-3936: `actor="system"`, `client` argument removed entirely, and detail carries file_id + uploader + uploader_id, with a comment naming BACKLOG #1224 and citing ADR 0150 decision 4 for dropping the client. Site 2 (background UploadRetentionRunner) is messagefoundry/api/app.py:5891-5907: the lifespan-owned `_audit_upload_prune` closure also writes `actor="system"` with uploader/uploader_id in detail. Enumerated prune call sites repo-wide: `prune_expired(` is called at app.py:3918 and uploads.py:648 (inside `run_once`) only, so at least those two are the whole surface and both are attributed to the system. Where the claim fails is the item's explicit third limb -- "assert the emitted row's actor is the system principal and its uploader detail is unchanged, for BOTH the request-path prune and the background runner". Only the request-path assertion exists (tests/test_upload_api.py:794-814, which also asserts the null client). The runner-path test (tests/test_uploads.py:389) injects a list-appending stub for `audit=` and asserts `[m.file_id for m in audited]`; it never constructs or inspects an audit row, so it would pass unchanged if `_audit_upload_prune` were reverted to `actor=meta.uploader`. That closure is reachable only from the API lifespan, and no test drives a lifespan, so the fixed line has no execution coverage at all. Under the not-deployed rule this is worded conditionally: the false attribution would no longer be written on a first deployment from either path, but the path the item singled out as the one a partial fix abandons is guarded by nothing. | +| 228 | **#1291** | Get-HandledTools returns NULL on its zero-tool and missing-file paths, correct only by its caller's grace | 2 | 2 | _fill-in_ | P3 | Unchanged: both exit paths at install-gate.ps1:244 and :250 still unroll, and the sole caller at :302 still re-wraps, so shipped behaviour is correct and the trap is latent exactly as filed. Value 2 is rung 2 -- developer tooling with no product, PHI or live-defect axis, and the one caller already covers it. Difficulty 2 for the two-token fix plus a test that must reach the function through ParseFile and FunctionDefinitionAst, since dot-sourcing install-gate.ps1 performs a machine-global install and a test routed through -Status cannot tell fixed from unfixed. | +| 229 | **#156** | Alert hysteresis (separate fire/clear thresholds) | 2 | 3 | _fill-in_ | DEMAND-GATE | The only occurrence of clear_depth or clear_oldest_seconds in the tree is the backlog's own scope sentence, and matching is still single-sided at alert_sinks.py:625-631 against the flat realert_seconds (settings.py:2919) plus per-rule cooldown_seconds (:2774). Value 2 is rung 2 -- an anti-flap refinement substantially covered by a genuine throttle. Difficulty 3 for two new AlertRule fields plus a clear-side matcher, with no store or migration work. | +| 230 | **#105** | Deterministic Corepoint-import tooling — Action-List → code-first scaffold (P3, deferred, owner decision) | 2 | 4 | _fill-in_ | DEMAND-GATE | Even finished this ships little worth: the adopter hand-ported, the AI /migrate covers the remaining path, and no demand is named, which is the substantially-covered band. The remainder is the deferred ide/ wrapper plus a reconciliation that needs an artifact the repository does not contain, behind the #313 multi-message Handler gate this item cannot buy. | +| 231 | **#1225** | Saved search presets are owner-scoped by the reassignable username, not the immutable `user_id` | 1 | 1 | _fill-in_ | P3 | Tried to prove the gap stands and failed on every limb. Checked the HEAD blob rather than only the working tree because git merge-base --is-ancestor 38ef285e HEAD returns false under squash-merge (the SDS-3.8 trap): app.py:4217 writes owner=identity.user_id, and :4169, :4247, :4282 read on user_id -- the write site the amendment added is the one that is fixed first. Looked for a bypass: webconsole/routes/search.py calls the same handlers across the CoreHandlers seam, harness/ and apiclient/ have zero search_preset references, and store.py/postgres.py/sqlserver.py take owner as an opaque string so no backend carries its own key. The item's own proof condition -- recycled name, not two live users -- exists as tests/test_search_presets_api.py:192 and passes (4 passed); it restores the orphan row after delete_user so it tests the KEY and not #1233's purge. Negative control: the #1226 screen, whose test asserts it caught all four preset sites pre-fix including the write, reports 8 candidates at HEAD with no preset site among them while still firing on the uploads and security_events_for shapes, so it is not blind. Schema comment store/store.py:1730 and ADR 0136 Amendment A both retract the username design. Value 1 and difficulty 1 price the remainder, which is a banner flip, not a build. | ## Ranked backlog — value × difficulty on a ten-level scale (re-scored 2026-08-03) @@ -15681,3 +15684,60 @@ cannot tell those apart has never demonstrated it is testing occupancy at all.* **Expiry:** this stops being right if `claim.ps1` gains a liveness probe, or if worktree directories are removed with their sessions. + +## 1350. research an honest pass for ASVS 1.3.4 -- an attacker-influenced image/svg+xml attachment is accepted and served, so the neither-accepts-nor-renders premise does not hold + +> 🔢 **Filed 2026-08-23 - not started. RESEARCH item: the goal is an HONEST pass, and "cannot honestly reach pass" is a valid finding.** ASVS **1.3.4** (L2) is one of the three cells the assessment record holds as CONTESTED between assessors rather than merely unbuilt, so the blocker is a reading to be settled before any build is scoped. Value **5/10** - Difficulty **3/10** - _fill-in_. +> Verdict: research +> Closing-act: scorecard-rescore + +**Cluster:** Security / ASVS remediation research. **Priority:** P2. **Verdict:** research. +**Severity:** on a first deployment, a partner feed carrying a crafted SVG attachment would be stored and served to an operator's browser. Whether that is inert depends entirely on controls whose coverage is unmeasured, which is a different claim from the control being absent. + +**The pinned verb.** The cell grades whether attacker-influenced content a browser will treat as ACTIVE is neutralised before it reaches a user agent. + +**What holds it short today.** An `image/svg+xml` OBX-5.2 attachment IS accepted, stored and served from the download route, so the prior not-applicable rationale -- that the engine neither accepts nor renders SVG -- is FALSE on the accepts conjunct. What is NOT missing is a control. `api/app.py` classifies browser-active MIME by SUBSTRING TOKEN (`html`, `xml`, `script`, `svg`, plus a `multipart` top-type rule), serves `default-src 'none'; sandbox` on attachments, forces `Content-Disposition: attachment` unconditionally, and sets `nosniff` globally. The substring form is deliberate and the code says why: the comment records that the browser-active types it lists would each slip past an equality or suffix check. + +**The research question.** Does the layered control fully neutralise the taint, and can the classifier be evaded? Three sub-questions, and the first decides the cell. (1) Is the token list COMPLETE against browser-active types containing none of those substrings? (2) Does `sandbox` with no `allow-*` token hold for every path that serves stored content, or only the download route? (3) Is there any path serving attachment bytes that does NOT pass through `_safe_attachment_content_type`? A pass needs all three, because the classifier is what decides whether the sandbox applies at all. + +**What would NOT be an honest pass.** Reading the shipped CSP as sufficient without establishing the classifier's completeness. The sandbox is reached only for content the MIME check routes there, so citing the control while leaving its trigger unmeasured assumes the conclusion the research exists to test. Equally dishonest: restoring not-applicable on the render half alone, when it is the ACCEPTS half that was measured false. + +**Source:** filed 2026-08-23 by the ASVS-TRACKER seat, owner-directed. The 2026-08-08 ledger-coverage sweep that produced this family of items scanned the **partial and fail** cells and did not cover **needs-review**, so all three contested cells were absent from the ledger while 186 of their siblings carried an item. That is a gap in the sweep's domain, not a lapse in anyone's diligence -- and it is why a cell can stay contested for three weeks with nothing scheduling it. + +## 1352. research an honest pass for ASVS 11.4.4 -- key-derivation parameters that balance security against brute force, at every derivation site + +> 🔢 **Filed 2026-08-23 - not started. RESEARCH item: the goal is an HONEST pass, and "cannot honestly reach pass" is a valid finding.** ASVS **11.4.4** (L2) is one of the three cells the assessment record holds as CONTESTED between assessors rather than merely unbuilt, so the blocker is a reading to be settled before any build is scoped. Value **5/10** - Difficulty **3/10** - _fill-in_. +> Verdict: research +> Closing-act: scorecard-rescore + +**Cluster:** Security / ASVS remediation research. **Priority:** P2. **Verdict:** research. +**Severity:** on a first deployment, an under-parameterised derivation would make offline attack on a stolen local-user hash cheaper than intended, and nothing in the system would report that the parameters were never chosen. + +**The pinned verb.** "...when deriving secret keys from passwords" -- plus the sentence the prior assessment dropped: "The parameters in use must balance security and performance to prevent brute-force attacks from compromising the resulting cryptographic key." + +**What holds it short today.** The prior not-applicable rested on a TRUNCATED quotation. The second sentence above was cut from the quoted verb, and it is the clause that bites: it does not ask whether a password-based derivation exists, it asks whether its PARAMETERS were chosen against brute force. `auth/passwords.py` derives with argon2id through argon2-cffi, and `auth/service.py` runs it off-thread under a concurrency cap. By the prior assessment's own account its three searches were incomplete, so the site inventory is not established either. + +**The research question.** Two questions. First, are the argon2id parameters -- time cost, memory cost, parallelism -- RECORDED and JUSTIFIED anywhere, or inherited from library defaults with no statement of intent? A default can be a perfectly good choice and still fail this requirement, which asks that a balance have been STRUCK rather than that a particular value be used. Second, what is the COMPLETE set of password-based derivation sites? The concurrency cap is evidence the cost was tuned for throughput; whether it was tuned for resistance is the open half. + +**What would NOT be an honest pass.** Citing argon2-cffi's defaults as the answer. Inheriting a default is not a balance struck, it is a balance not considered, and the two are indistinguishable in the record unless the reasoning is written down. Also not a pass: an inventory that stops at the first three search patterns, which is precisely how the prior reading went wrong. + +**Source:** filed 2026-08-23 by the ASVS-TRACKER seat, owner-directed. The 2026-08-08 ledger-coverage sweep that produced this family of items scanned the **partial and fail** cells and did not cover **needs-review**, so all three contested cells were absent from the ledger while 186 of their siblings carried an item. That is a gap in the sweep's domain, not a lapse in anyone's diligence -- and it is why a cell can stay contested for three weeks with nothing scheduling it. + +## 1351. research an honest pass for ASVS 10.4.13 -- whether the OIDC authorization request must leave the browser-visible front channel + +> 🔢 **Filed 2026-08-23 - not started. RESEARCH item: the goal is an HONEST pass, and "cannot honestly reach pass" is a valid finding.** ASVS **10.4.13** (L3) is one of the three cells the assessment record holds as CONTESTED between assessors rather than merely unbuilt, so the blocker is a reading to be settled before any build is scoped. Value **4/10** - Difficulty **3/10** - _fill-in_. +> Verdict: research +> Closing-act: scorecard-rescore + +**Cluster:** Security / ASVS remediation research. **Priority:** P3. **Verdict:** research. +**Severity:** on a first deployment, authorization request parameters would be visible and modifiable in the user agent, with the identity provider the only party positioned to reject tampering. That is a real exposure, but it is not a session-forging path. + +**The pinned verb.** The cell turns on whether the authorization request may travel a channel the browser can see and modify. + +**What holds it short today.** `auth/oidc/flow.py` builds an authorization-code redirect with PKCE S256 and `response_mode=query`, so the authorization request travels the browser-visible front channel BY CONSTRUCTION. Nothing here is broken or missing -- it is a conventional, correct authorization-code-with-PKCE implementation. What is contested is whether the pinned requirement is satisfied by it. The record notes this cell moved off not-applicable WITHOUT any factual claim in the derivation being refuted: the facts held and the reading did not. + +**The research question.** Does the pinned text require Pushed Authorization Requests (RFC 9126), moving the authorization request to a back channel, or does PKCE on the front channel answer it? Settle it by reading the pinned verb against the flow, not against general OAuth practice. If PAR is required, the follow-on is what a PAR client costs here and whether the identity providers in scope support it. The SMART backend-services path is a separate profile and should be graded separately rather than folded in. + +**What would NOT be an honest pass.** Treating PKCE as a general answer to a requirement about request confidentiality. PKCE binds an authorization code to the client that requested it; it does not conceal or integrity-protect request parameters travelling through the user agent. If the pinned text asks for the latter, PKCE is the wrong control cited for a right-sounding reason. Equally: restoring not-applicable because no in-scope identity provider supports PAR -- deployment availability is a difficulty input, not a scope argument. + +**Source:** filed 2026-08-23 by the ASVS-TRACKER seat, owner-directed. The 2026-08-08 ledger-coverage sweep that produced this family of items scanned the **partial and fail** cells and did not cover **needs-review**, so all three contested cells were absent from the ledger while 186 of their siblings carried an item. That is a gap in the sweep's domain, not a lapse in anyone's diligence -- and it is why a cell can stay contested for three weeks with nothing scheduling it. From 7e367411331f032c9a781ae040095f23680a944a Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 23 Aug 2026 15:54:34 -0500 Subject: [PATCH 06/10] adr(0173): TLS peer revocation and OCSP stapling, both graded directions (BACKLOG #1005, ASVS 12.1.4) Drafted by a bounded workflow and then attacked by four independent refuters: citation checking, runtime re-measurement, house-rule hunting, and one arguing the decision is wrong. Zero blocking findings. Eight repairs applied before filing, each re-verified against the code by hand rather than taken on the refuter's word. DECISION: accept and document both graded directions, plus one build rider the accept reasoning does not cover -- three originating hops that never reach the existing revocation guard, filed by subject and deliberately unallocated per the no-dangling-#N rule. The load-bearing distinction: direction 1 (stapling) is UNBUILDABLE, not unbuilt. No stapling surface exists in the ssl module API at all, measured against live positive controls. The opt-in client-certificate CRL checking that DOES ship is a THIRD combination -- peer revocation on the terminating side -- and moves neither graded direction. That is the single easiest thing in this area to misread, so it gets a two-axis table and a bolded rule. REPAIRS, and the pattern in them is worth recording: Three table rows cited the NON-verifying arm of an `if verify:`. The SMTP row cited tls_policy.py:1218, the `else` branch that goes on to set CERT_NONE, while the verifying context is built at :1216. Same shape in the REST and postgres rows. A table listing verifying hops cited the lines reached only when the hop does not verify. "nine guarded originating hops" where the list has seven, in an ADR that argues elsewhere that a hardened count is a liability. The syslog forwarder was said to "additionally lack" harden_verify_flags, presented as distinguishing. Measured: all three unguarded hops lack it, against a positive control of seven files that carry it. The corrected reading strengthens the rider. "the pinned runtime", twice. THERE IS NO PIN: pyproject.toml:28 is requires-python = ">=3.14", a floor with no implementation constraint, and no .python-version, .tool-versions or runtime.txt exists. Now scoped to what is true and sufficient -- every interpreter this project builds against -- with the re-check trigger deliberately not keyed to CPython. This defect was caught by the owner, not by the four refuters; the sibling ADR's refuters found the identical phrase and killed it. Index row added in this commit, per the ledger gate. Co-Authored-By: Claude Opus 5 --- ...ss-terminating-and-originating-surfaces.md | 496 ++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 497 insertions(+) create mode 100644 docs/adr/0173-tls-peer-revocation-checking-and-ocsp-stapling-across-terminating-and-originating-surfaces.md diff --git a/docs/adr/0173-tls-peer-revocation-checking-and-ocsp-stapling-across-terminating-and-originating-surfaces.md b/docs/adr/0173-tls-peer-revocation-checking-and-ocsp-stapling-across-terminating-and-originating-surfaces.md new file mode 100644 index 00000000..a3b6ebfd --- /dev/null +++ b/docs/adr/0173-tls-peer-revocation-checking-and-ocsp-stapling-across-terminating-and-originating-surfaces.md @@ -0,0 +1,496 @@ + + + +# ADR 0173 — TLS peer revocation checking and OCSP stapling across terminating and originating surfaces + +- **Status:** Proposed (2026-08-23) — no code in this change. The accept half needs owner ratification; + the one build rider in §4.3 is a separate, small change that this ADR authorizes but does not perform. +- **Date:** 2026-08-23 +- **Deciders:** owner (to ratify) · security working group +- **Related:** **extends [ADR 0078](0078-certificate-revocation-posture.md)** (Accepted 2026-07-10, + owner-ratified — that ADR made the `[api]` in-process delegation an *enforced* start-time refusal; + this one grades the two remaining directions and records why one of them is unbuildable) · + [ADR 0002](0002-phase2-transport-security-and-strong-auth.md) §"Certificate revocation (12.1.4)" + (the original documented residual) · [ADR 0023](0023-inbound-http-listener.md) (the inbound HTTP + listener, which inherits the MLLP builder) · [ADR 0024](0024-smart-backend-services-token-provider.md) (the + SMART token provider) · [ADR 0025](0025-dicom-codec-store-connectors.md) (DICOM SCP/SCU) · + [ADR 0092](0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md) (the + posture-keyed cleartext/verify-off refusals this composes with) · [ADR 0148](0148-phi-default-posture-and-an-explicit-security-enforcement-level.md) (the + posture the gates key on) · BACKLOG **#201**, **#1005** · **ASVS 12.1.4 (L3)** · + [CLAUDE.md](../../CLAUDE.md) §0 (not-deployed beta), §2 (on-premises, offline by default), §11 + (SDS-3.5 to SDS-3.8 — reviewing security prose) +- **Code references** are this worktree at `3f18051b`. `e52055c7` was cited while drafting and is no longer an ancestor of HEAD; `git diff e52055c7 HEAD -- messagefoundry/` is EMPTY, so every code line below is byte-identical either way. The revocation code cited + below is byte-for-byte present on `origin/main` at `06ef8ec8` at the **same line numbers** + (`harden_crl_check` at `tls_policy.py:215`, its three call sites at `api/tls.py:69`, + `mllp.py:558`, `dicom.py:150`, and `check_inbound_revocation` in `wiring_runner.py`), verified with + `git grep` against `origin/main`. Line numbers drift; locate exactly at implementation time. + +--- + +## 1. Context + +### 1.1 What ASVS 12.1.4 asks, in two directions + +The requirement asks that "proper certificate revocation, such as OCSP stapling, is enabled and +configured". That sentence covers two independent behaviours, and they have different remedies: + +| # | Direction | The engine's role | The question it answers | +|---|---|---|---| +| **1** | **Staple my own status** | **terminating** (the engine is the TLS server) | can a client learn, from my handshake, that my certificate is still good, without fetching from my issuer? | +| **2** | **Check the peer's status** | **originating** (the engine is the TLS client) | is the certificate this partner just presented revoked? | + +Neither is built. The rest of this section separates *why* per direction, because the two answers do +not share a fix. + +### 1.2 THE RUNTIME CANNOT STAPLE, IN EITHER DIRECTION — and that is the first fact, not a footnote + +Direction 1 is **unbuildable on every interpreter this project builds against**, not merely unbuilt. **THERE IS NO PINNED RUNTIME AND THIS ADR MUST NOT IMPLY ONE** -- corrected 2026-08-23 after the sibling ADR was refuted on exactly that phrase. `pyproject.toml:28` is `requires-python = ">=3.14"`, a FLOOR with no implementation constraint; there is no `.python-version`, `.tool-versions` or `runtime.txt`, and no OpenSSL pin anywhere. What is true is narrower and sufficient: CI exercises CPython 3.14 and the free-threaded 3.14t through `actions/setup-python`, no code branches on `sys.implementation` (zero occurrences), and the stapling surface is absent from the `ssl` module API that every conforming implementation shadows. The distinction decides +everything downstream: "nobody built it" opens a build item, "the runtime exposes no hook" does not. + +Measured on this worktree, CPython 3.14.6 (`tags/v3.14.6:c63aec6`, Jun 10 2026) / OpenSSL 3.5.7 +(9 Jun 2026): + +``` +sorted(a for a in dir(ssl.SSLContext) if any(k in a.lower() for k in ('ocsp','staple','status'))) -> [] +sorted(a for a in dir(ssl.SSLSocket) if ...same filter...) -> [] +sorted(a for a in dir(ssl.SSLObject) if ...same filter...) -> [] +``` + +**Positive controls, same `dir()` technique, so an empty list means absence rather than a dead +probe:** filter `verify` on `SSLContext` returns `['load_verify_locations', +'set_default_verify_paths', 'verify_flags', 'verify_mode']`; filter `cert` on `SSLSocket` returns +`['getpeercert']`. The instrument returns real attributes when they exist. + +There is no `set_ocsp_server_callback` equivalent, no `status_request` extension surface, and no way +to attach a cached OCSP response to a stdlib handshake. **This is not a configuration the engine +failed to set. There is no configuration.** + +Module-level `ssl` exposes exactly three names matching `crl|ocsp|staple`: +`VERIFY_CRL_CHECK_CHAIN`, `VERIFY_CRL_CHECK_LEAF`, and `enum_crls`. The first two check a CRL that +was **already loaded from a file** into the trust store. `enum_crls` is Windows-only and returns raw +bytes out of the OS certificate store; nothing in `messagefoundry/` calls it. **There is no fetch of +any kind**, so direction 2 is partly blocked too: the engine cannot request or read a stapled +response, and cannot do an OCSP or CRL-distribution-point fetch. What it *can* do is check a CRL an +operator placed on disk. + +So the single revocation mechanism this runtime offers is a file-loaded CRL, and §1.4 shows the +engine built exactly that — on a third combination that neither graded direction covers. + +### 1.3 What exists today on the two graded directions: nothing + +**Four terminating surfaces. Zero stapling calls.** Each builds a `PROTOCOL_TLS_SERVER` context, +loads a chain, hardens key exchange, cipher suites and verify flags, and stops: + +| Terminating surface | Context built at | Hardening | Stapling | +|---|---|---|---| +| Engine API, `/ui`, `/ws/stats` | `messagefoundry/api/tls.py:43` | `:49-53` chain, `:56` kex, `:57` suites, `:58` verify flags | none | +| MLLP inbound listener | `messagefoundry/transports/mllp.py:545` | `:549` chain, `:559-561` | none | +| Inbound HTTP/1.1 listener (ADR 0023) | `messagefoundry/transports/http_listener.py:419` | inherits the MLLP builder verbatim (`_mllp_ssl_context(s, server=True)`) | none | +| DICOM C-STORE SCP | `messagefoundry/transports/dicom.py:131` | `:142` chain, `:151-153` | none | + +A repository-wide grep for `stapl` across `messagefoundry/` returns **six hits and every one is +prose** — a docstring or an operator-facing error string: `config/tls_policy.py:18`, `:203`, `:332`; +`pipeline/wiring_runner.py:6793`; `__main__.py:1716`, `:1734`. Zero code. Positive control on the +same path and technique: `harden_verify_flags` returns hits in seven files. + +**Thirteen originating hops. None checks revocation.** Every one builds a verifying client context +and gets `harden_verify_flags` at most, which ORs only `ssl.VERIFY_X509_STRICT` +(`config/tls_policy.py:209-212`). Its own docstring states the limit: *"This is **strict validation, +not revocation checking**"* (`:202`). + +| Originating hop | Context built at | What it carries | +|---|---|---| +| MLLP outbound | `transports/mllp.py:583`, `:585`; flags `:601` | HL7 message bodies | +| DICOM C-STORE SCU | `transports/dicom.py:454`, `:457`; flags `:471` | imaging headers | +| FTPS | `transports/remotefile.py:228`; flags `:253` | message files | +| REST / FHIR / DICOMweb / SOAP | `transports/soap.py:202`; the HTTP family rides urllib's own context via `_NO_REDIRECT_OPENER` (`transports/rest.py:267`) and builds none of its own -- `rest.py:275` and `:296` are the dev-escape openers, not this path | message bodies | +| SMTP and alerting egress | `config/tls_policy.py:1216`; flags `:1235` | alerts, Direct payloads | +| Shared pinned-anchor client builder | `config/tls_policy.py:1172`, `:1178` | anchors only; never touches `verify_flags` | +| Engine-to-store (asyncpg) | `store/postgres.py:752`, and `:760` where asyncpg builds it (`:729` is the `trust_server_certificate` escape, `CERT_NONE` at `:731`) | the whole PHI store | +| Syslog / SIEM forwarder (RFC 5425) | `logging_setup.py:310` | audit records | +| OIDC / IdP token and JWKS legs | `auth/oidc_http.py:99-101` | the client secret, the authorization code | +| SMART Backend Services token endpoint | `transports/smart.py:183-184` | a signed `client_assertion`, a bearer token | +| Shared engine-API client | `apiclient/client.py:214`, `:222` | session credentials | +| Windows tray probe | `tray/probe.py:123` | health only | +| TLS version prober | `config/tls_probe.py:97` | nothing; it scopes itself out at `:32` | + +Two of these say so in their own shipped prose, which is the posture this ADR preserves: +`transports/rest.py:663` — the hop verifies the chain "(+ strict RFC 5280) but performs NO OCSP/CRL, +so a revoked-but-unexpired server cert is still accepted" — and `store/postgres.py:737`, the same +sentence for asyncpg. + +### 1.4 ABSENT versus PRESENT-BUT-ORTHOGONAL — the one thing a reader will get wrong + +**The engine DOES ship revocation checking. It is on neither graded direction.** Anyone who greps for +CRL code, finds `harden_crl_check`, and concludes 12.1.4 is partly satisfied has read the wrong axis. +Two axes, not one: + +| | **terminating** (engine is the server) | **originating** (engine is the client) | +|---|---|---| +| **check MY OWN status** | **Direction 1. ABSENT — unbuildable (§1.2).** No stapling hook exists. | not a thing | +| **check the PEER's status** | **Direction 3. PRESENT, opt-in, default off.** `harden_crl_check` on a partner's *client* certificate. **Neither graded direction.** | **Direction 2. ABSENT.** Every client context gets strict path validation only. | + +**Direction 3 is genuine revocation checking and it moves neither cell.** It is the same verb as +direction 2 — check the peer — performed in the **server** role, against a partner's client +certificate under mTLS. It staples nothing, so direction 1 is untouched. It never runs on an +originating context, so direction 2 is untouched. + +The measurement, with a positive control: + +- **`ssl.VERIFY_CRL_CHECK` appears EXACTLY ONCE in all of `messagefoundry/`:** + `config/tls_policy.py:276`, inside `harden_crl_check` (defined `:215-276`). + Positive control for that search: the same grep over the same path returns `harden_verify_flags` in + seven files, so the pattern and the path were live. +- **Exactly three call sites, and all three are `PROTOCOL_TLS_SERVER` contexts inside an + mTLS-only branch:** `api/tls.py:69` (nested in `if client_ca is not None:` at `:60`), + `transports/mllp.py:558` (nested in `if ca:` at `:550`), `transports/dicom.py:150` (nested in + `if ca:` at `:143`). The inbound HTTP listener is a fourth beneficiary for free, because + `http_listener.py:419` reuses the MLLP server builder. +- **The helper is careful, and each refusal is a measured failure mode** + (`tls_policy.py:222-247` records them): it refuses a missing file (`:251-255`), refuses a CRL past + `nextUpdate` (`:261-266`, because an expired CRL refuses *every* client rather than only revoked + ones), loads through `cafile=` only, and asserts `cert_store_stats()["crl"] >= 1` (`:269-275`) + because `cadata=` loads zero CRLs from the same bytes while still setting the flag. +- **Every default is off.** `config/settings.py:779` (`tls_client_crl_file: str | None = None`) and + `config/wiring.py:1071` (MLLP), `:1356` (Http), `:2200` (DICOM) — all `None`. + +So: the one mechanism the runtime offers is the one the engine built, and it built it on the axis +12.1.4's two graded directions do not name. + +### 1.5 What the engine does instead: four enforced refusals and an attestation escape + +The engine does not accept the gap quietly. It converts it into refusals that **admit the engine +performs no revocation** rather than claiming it does, which is what keeps them clear of SDS-3.7's +false-premise defect: + +1. **Originating hops — `RevocationHopGuard`.** Refuses an off-loopback production-PHI verifying hop + unless revocation is proven in front or attested. Disposition at `config/tls_policy.py:963-973`; + the operator-facing detail at `:1032-1038`; `enforce_construction` at `:1040`. Wired at + `transports/mllp.py:751-752`, `rest.py:1347`, `soap.py:403`, `fhir.py:370`, `dicomweb.py:261`, + `email.py:239`, and `store/postgres.py:745` via `_refuse_store_revocation` (`:763`). +2. **Terminating `[api]` TLS — `in_process_tls_revocation_refused`** (`config/tls_policy.py:344`), + wired at `__main__.py:1722`. `serve` refuses an in-process off-loopback `[api]` TLS bind unless a + declared TLS-terminating reverse proxy (WP-15) or `MEFOR_TLS_REVOCATION_ATTESTED` proves + revocation in front. Loopback and proxy-terminated binds never trip it. +3. **Terminating mTLS listeners — `check_inbound_revocation`** (`pipeline/wiring_runner.py:6779`, + body `:6800-6826`). Refuses an mTLS listener that verifies client certificates with no + `tls_crl_file`, on an enforcing production-PHI instance. +4. **Attestation escape, both directions.** `Source.tls_revocation_attested` + (`config/models.py:269`, default `False`), `Destination.tls_revocation_attested` + (`models.py:655`), and the blanket env read at `tls_policy.py:327-335` + (`MEFOR_TLS_REVOCATION_ATTESTED`, constant at `:53`). An attestation that suppresses a would-be + production-PHI refusal is **audited** at `tls_policy.py:1051-1060`. +5. **CRL expiry monitoring**, because an expired CRL refuses every client rather than only revoked + ones: `MonitoredCert(kind="crl")` at `pipeline/cert_expiry.py:64`, inbound enrolment `:136-138`, + dispatch `:261-271`, facts `:293-300`; the sink method at `pipeline/alerts.py:101` and the + expired case logged at ERROR at `:317-331`. + +**Rider 3 makes the load-bearing point in shipped code** (`wiring_runner.py:6791-6795`): +`harden_verify_flags` delegates live revocation to a proxy plus the OS trust store, which is credible +for the API and UI surface, but **an HTTP proxy can terminate neither MLLP framing nor DIMSE**. For +those two listeners the documented delegation does not reach, so the gate raises rather than resting a +control on a premise that is false for that transport. + +### 1.6 Three hops cross with no signal at all + +Three originating hops have **no guard of any kind** — no refusal, no warning, no audit line — and two +of the three carry authentication material: + +| Hop | Site | Crosses with | +|---|---|---| +| SMART Backend Services token endpoint | `transports/smart.py:183-184` | a signed `client_assertion` JWT out, a bearer token back | +| OIDC / IdP token and JWKS legs | `auth/oidc_http.py:99-101` | the client secret, the authorization code, the identity assertion (its own comment, `:105-107`) | +| Syslog / SIEM TLS forwarder | `logging_setup.py:310` | audit records; no `harden_verify_flags` either -- shared with the other two unguarded hops, not peculiar to this one | + +Measured: a grep for `revocation` in `transports/smart.py` returns **zero**. Positive control on the +same file, same technique: `refuse_cleartext_credential_hop` is imported at `:61` and called at +`:143`, so the file already reaches into `rest.py`'s refusal helpers and the grep was live. + +The SMART module's trust argument (`smart.py:23-28`) does not cover this. It says `token_url` is +operator-pinned with no `.well-known` discovery and gated by `[egress].allowed_http`. **Pinning the +name does not check the certificate's status**, which is the whole point of revocation. + +### 1.7 Beta framing (CLAUDE.md §0) + +**Nothing above is a live exposure. There are zero deployments.** Stated conditionally: on first +deployment, a partner client certificate revoked this morning **would** keep authenticating to an +mTLS MLLP, HTTP or DIMSE listener until its `notAfter` unless the site sets `tls_crl_file` or +attests; a revoked peer server certificate **would** be accepted on every originating hop; and a +peer that requires a stapled response from an engine-terminated listener **would** fail the +handshake. There is likewise **no migration cost** to anything decided here — nothing to break and +nobody to notify. This never relaxes a control. + +## 2. Decision + +**Accept the ASVS 12.1.4 gap on both graded directions as a documented, enforced delegation — with +one build rider that the accept reasoning does not reach.** + +- **Direction 1 (staple my own status while terminating): ACCEPT, and record it as RUNTIME-BLOCKED.** + It is not implementable at any effort level on any interpreter this project builds against (§1.2, and see the scoping note there -- there is no pin, only a `>=3.14` floor). This closes it to build + items until the runtime or the dependency set changes (§6). +- **Direction 2 (check the peer's status while originating): ACCEPT, delegated and enforced.** The + only mechanism the stdlib offers a client is a file-loaded CRL with no fetch, and where the engine + can enforce a decision it already refuses the hop rather than crossing it quietly (§1.5). +- **Direction 3 (the shipped opt-in client-certificate CRL): unchanged, and it does not count toward + either graded direction.** It stays opt-in and default-off. The record must not cite it as partial + 12.1.4 coverage. +- **The three unguarded hops in §1.6 are NOT accepted.** Accepting the *mechanism* is not accepting + *silence*. Closing them needs no new dependency, no new control and no stapling, so the reasoning + that justifies the rest does not apply to them. See §4.3. + +### 2.1 Why accept rather than build + +**Direction 1 has no build to authorize.** The one library that exposes OCSP handshake callbacks is +pyOpenSSL, and it is deliberately excluded from core: `pyproject.toml:160-161` records it as +"maintenance-mode upstream; hard-caps cryptography<51 — kept OUT of core so repo-wide cryptography +upgrades stay uncoupled". It resolves only transitively through the optional `webauthn` extra +(`requirements.lock:738`, `pyproject.toml:169`), and nothing under `messagefoundry/` imports +`OpenSSL.SSL`. Building direction 1 means reversing a recorded dependency decision, then adding a +fetch-and-refresh daemon with a cache, a refresh timer and a fail-open-or-closed ruling — an outbound +network dependency that contradicts the offline-by-default premise stated at `tls_policy.py:18` and +`__main__.py:1713-1716`. + +**Honest limit on that claim:** pyOpenSSL is not importable in this venv (`ModuleNotFoundError: No +module named 'OpenSSL'`), so `set_ocsp_server_callback` was not `hasattr`-verified on 26.4.0 and is +not asserted here. The cost above is a floor, not a ceiling. + +**And stapling protects the peer, not the engine.** A server that staples gains nothing itself; the +benefit accrues to the client, which is spared a fetch. Without the RFC 7633 must-staple extension on +the certificate, stapling is **soft-fail by design** — a client receiving no stapled response simply +continues. So the engine could staple flawlessly on all four surfaces and the observable delta could +be zero, because the property that makes it load-bearing lives in the peer's configuration, which the +engine cannot see, cannot enforce and gets no signal about. On MLLP and DIMSE those peers are partner +interface engines and modality gateways. Asserting that they consume stapled responses would be a +compensating control resting on an unverified assumption about third-party software, which SDS-3.7 +forbids. + +**Direction 2's buildable half is declined for now, on cost rather than on principle.** ADR 0078's +rejection of an in-engine client attacked **fetching**, and that reasoning does not reach a CRL an +operator drops on disk. A file-based CRL on originating hops is implementable today with the existing +`harden_crl_check`, unchanged. It is declined here for three reasons, and the third is the strongest: + +1. **Coverage is narrower than the delegation it would sit beside.** File CRL reaches only hops with a + pinned per-connection anchor. It cannot reach the HTTP family (REST, FHIR, DICOMweb, SOAP), which + rides urllib's own context on the OS trust store — the largest group of originating hops. A site + that terminates outbound at a revocation-checking egress proxy covers all of them. +2. **It is unusable on the default trust posture.** `build_verifying_client_context` + (`tls_policy.py:1171-1178`) loads the OS roots on the `system` and `augment` postures. With the + check flag set and a CRL loaded for one CA only, a peer chaining to any other trusted CA is refused + with `unable to get certificate CRL`. So the build would need a hard refusal of CRL-plus-system- + roots, which narrows it further. +3. **It converts a confidentiality gap into an availability hazard on the delivery path.** The engine + already says so at `tls_policy.py:231-233`: an expired CRL refuses every peer. `harden_crl_check` + catches a stale file **at construction**, where `check` and dry-run see it — but not the live case, + where a CRL passes `nextUpdate` while the engine runs and every outbound delivery on that + connection begins failing at once. The staged pipeline retries and dead-letters rather than losing + messages, so it is a stall and a growing backlog, not data loss. It is still an outage caused by a + control the operator installed. Against that, the security gain is bounded by publication latency: + a CRL is a snapshot, so the revocation window stays days wide. + +**This decline is a cost judgment on a currently-buildable control, not an impossibility finding.** +Do not restate it as one. §6 names what would flip it. + +## 3. Acceptance Criteria + +> Behavioural criteria in EARS form. Criteria 1 to 3 describe **shipped** behaviour this ADR ratifies +> rather than commissions, and link to the tests that already hold it. Criteria 4 and 5 are the §4.3 +> build rider and carry no test ref until it lands. + +- **AC-1** — WHERE an mTLS listener requires and verifies a client certificate and no `tls_crl_file` + is configured, on an enforcing production-PHI instance, THE SYSTEM SHALL refuse the wiring rather + than start + → `tests/test_hop_refusal_wiring.py::test_mtls_without_a_crl_is_refused_on_an_enforcing_phi_instance`, + `::test_a_configured_crl_passes`, `::test_tls_without_mtls_is_not_a_revocation_gap`. +- **AC-2** — WHEN a CRL is configured on an mTLS listener, THE SYSTEM SHALL refuse a revoked client + and SHALL accept a good one, and WITHOUT the CRL SHALL accept the same revoked client + → `tests/test_tls_policy.py::test_a_revoked_client_is_refused_by_a_crl_checked_context`, + `::test_a_good_client_is_accepted_by_the_same_context`, + `::test_without_the_crl_the_revoked_client_gets_in`. **The third is the untouched baseline and is + the load-bearing one**: without it the pair cannot distinguish enforcement from blanket refusal. +- **AC-3** — IF a configured CRL file is missing, carries no CRL, or is already past `nextUpdate`, + THEN THE SYSTEM SHALL raise at context construction rather than advertise revocation checking and + perform none + → `tests/test_tls_policy.py::test_harden_crl_check_refuses_a_missing_file`, + `::test_harden_crl_check_refuses_a_file_carrying_no_crl`, + `::test_harden_crl_check_refuses_an_already_expired_crl`. +- **AC-4** — WHERE an originating hop carries authentication material or audit records over verified + TLS to a non-loopback host (the SMART token endpoint, the OIDC token and JWKS legs, the syslog + forwarder), THE SYSTEM SHALL apply the same posture-keyed revocation disposition the other + verifying hops apply, and SHALL NOT cross with no refusal, no warning and no audit entry + → test ref added by the §4.3 build. +- **AC-5** — THE SYSTEM SHALL NOT assert in code, docstring, error text or documentation that it + performs certificate revocation checking on either graded direction, and any prose naming the + shipped client-certificate CRL SHALL state that it is the peer's certificate on a terminating + listener → review gate; no test. + +## 4. What this decides, hop by hop + +### 4.1 Accepted with an enforced refusal in front + +The `[api]` in-process bind (`__main__.py:1722`), the mTLS listeners (`wiring_runner.py:6779`), and +the seven guarded originating hops listed at §1.5 rider 1. On a named environment these refuse rather +than warn: `config/settings.py:2338-2342` derives `DataClass.PHI` for `dev`, `staging` **and** `prod`, +and `settings.py:3675` defaults enforcement to `SecurityEnforcement.ENFORCE`. The disposition ladder +at `tls_policy.py:963-973` has no global audited-opt-out arm (`:958-960`), so the only relaxations are +loopback, a proven terminator, an attestation, or a non-PHI posture. + +**One caveat, stated so nobody reads more coverage into it than exists:** +`RevocationHopGuard.enforce_construction` **no-ops when the posture is unstamped** (`tls_policy.py:1044-1046`) +— a live serve build after the pre-flight, or a direct test or embedding. The `build_check` gate is +the authority there, by design. A configuration that names no environment and declares no data class +lands on the not-PHI ALLOW arm and gets nothing. + +### 4.2 Accepted with no risk to accept + +- **The default `[api]` bind** — `config/settings.py:726` (`host: str = "127.0.0.1" # Phase 1 = + localhost only`). Loopback short-circuits the refusal. No TLS terminated, no peer certificate, + nothing to revoke. The web console is same-origin behind it. +- **Any inbound without mTLS** — `wiring_runner.py:6805-6806` returns early. No client certificate is + requested, so none can be revoked. +- **Raw TCP and X12 inbound** — `wiring_runner.py:6797-6799` records that they have no TLS option. +- **The TLS version prober** — `config/tls_probe.py:97`, which scopes itself out at `:32`: verifying + real traffic "is a different control (12.1.4 / `harden_verify_flags`)". + +### 4.3 NOT accepted — the build rider + +The three hops at §1.6 get the same posture-keyed treatment their siblings already have. The helper +exists and the call is one statement: `refuse_unrevoked_verified_hop(scheme, url, connector=, +revocation_attested=)` at `transports/rest.py:656-679`, called in exactly that shape by five +siblings (`fhir.py:370`, `dicomweb.py:261`, `rest.py:1347`, `soap.py:403`, and `email.py:239` via +`RevocationHopGuard.capture`). **All three unguarded hops also lack `harden_verify_flags`** -- measured at HEAD, it occurs zero times in `logging_setup.py`, `auth/oidc_http.py`, `transports/smart.py` and `transports/rest.py` alike, against a positive control of seven files that do carry it. An earlier draft of this ADR presented the omission as peculiar to syslog; it is not, and the corrected reading strengthens the rider rather than weakening it -- the gap spans the group. + +**Filed by subject, deliberately unallocated** (CLAUDE.md §"Never CITE a `#N` you have not +allocated"): *revocation-guard parity for the SMART token endpoint, the OIDC token and JWKS legs, and +the syslog forwarder*. Allocate a number when the build starts. + +**Why this is not folded into the accept.** The accept rests on "the mechanism cannot be built without +reversing a dependency decision". That reasoning does not touch these three at all — the guard is +already written and already wired six times. Leaving them out would also harden a count into the +record: `transports/direct.py:250-253` shows, in a shipped comment, that DIRECT declined a +`RevocationHopGuard` partly because adding it "would make the enumerated count eight and force four +'seven verifying hops' docs to change". That comment gives a real second reason and records the +choice honestly, which is the right instinct. But SDS-3.6 says a completeness claim is a liability, +and an accept-and-document ruling that left three unguarded credential hops outside the count would +write a number a reader treats as coverage. + +## 5. Consequences + +**Positive** + +- The 12.1.4 record stops oscillating between "unbuilt" and "impossible" for direction 1. The runtime + probe in §1.2 is reproducible in under a second, with no engine tree, corpus or network. +- Direction 3 stops being miscounted as partial coverage of directions 1 or 2. +- The three unguarded credential hops are named and owned instead of being invisible inside an + enumeration. +- Nothing in the tree claims a revocation check it does not perform. The refusals name the missing + check and the two ways to prove it in front (`tls_policy.py:1033-1037`, `__main__.py:1729-1736`). + +**Negative and residual** + +- **ASVS 12.1.4 stays delegated.** The engine performs no revocation on either graded direction, and + any scorecard reasoning has to keep saying so. +- **The blanket attestation is coarser than the problem.** `tls_policy.py:327-335` reads one env var + and `capture` ORs it into every hop (`:1018`), plus the in-process `[api]` listener and every + inbound mTLS gate. Per-connection flags exist and default `False` (`models.py:269`, `:655`), but the + env var is the easier reach and both refusal texts offer it by name. The crossing is audited + (`tls_policy.py:1051-1060`); nothing stops a one-hop need becoming an instance-wide posture. +- **The refusals are a real operator cost, not a free control.** Because all three named environments + derive PHI and enforcement defaults to ENFORCE, a first deployment wiring any off-loopback partner + hop hits a construction-time refusal and must either terminate at a revocation-checking egress proxy + or attest. Some sites will attest without a revocation-checking PKI behind the claim, because + attesting is one environment variable and standing up a proxy is a project. The control is honest by + construction — the flag is named an attestation and its use is audited — but the residual risk moves + into a claim the engine cannot check. +- **Direction 3 remains opt-in and default-off**, so a site that configures nothing gets no + client-certificate revocation and, on an enforcing PHI posture, a start-time refusal telling it so. +- **An operator who enables direction 3 takes on CRL refresh.** The engine alarms on expiry + (`cert_expiry.py:261-271`, ERROR at `alerts.py:317-331`) but cannot fetch a replacement. + +**Out of scope** + +- Any AIA or CRL-distribution-point fetch, in either direction. ADR 0078's offline-by-default + reasoning holds here with full force and is not reopened. +- `VERIFY_CRL_CHECK_CHAIN` and `capath=` hashed-directory CRL loading. `harden_crl_check`'s own + docstring (`tls_policy.py:237-247`) records that `capath=` was measured working while + `cert_store_stats()["crl"]` reports zero for it, so the helper's liveness assertion would reject a + working configuration. Anyone adding it needs a different proof. +- Any change to `apiclient/client.py:222` or `tray/probe.py:123`, which delegate chain building to the + OS verifier through `truststore`. Whatever the OS does there, the engine neither configures nor + observes it. + +## 6. What would trigger revisiting + +Any one of these reopens the corresponding direction. None of them is speculative housekeeping; each +names a fact that is checkable. + +1. **Any conforming interpreter or its OpenSSL grows a stapling surface.** Re-run the §1.2 probe with its positive controls. The trigger is deliberately not keyed to CPython: the project constrains the Python VERSION, not the implementation. + A non-empty result on `SSLContext` or `SSLSocket` retires the runtime-blocked finding for + direction 1 and turns it back into a build question. **Do not retire it on a release note; retire + it on the probe.** +2. **pyOpenSSL, or an equivalent exposing OCSP handshake callbacks, becomes a declared core + dependency for an unrelated reason.** The dependency argument in §2.1 is the larger half of the + direction 1 cost; if something else pays it, only the soft-fail argument remains, and that one is + about peer behaviour rather than about us. +3. **A deploying site reports a peer that requires a stapled response**, or a partner issues + must-staple certificates to this engine. That converts the soft-fail objection into a concrete + interoperability failure and makes an RFC 7633 must-staple **refusal** worth building: it depends + on no peer behaviour, it needs no network, and `cryptography` is already core + (`pyproject.toml:64`, resolved 50.0.0, `x509.TLSFeature` and `TLSFeatureType` verified present at + OID 1.3.6.1.5.5.7.1.24). Note the ordering scar first: `api/tls.py:64-67` warns that a check + placed beside `harden_verify_flags`, before the CA load, yields a context that refuses every + client. +4. **The HTTP-family hops gain a pinned per-connection trust anchor**, or the availability objection + in §2.1 is answered by an in-engine CRL refresh that does not fetch. Either removes a reason + direction 2's file-CRL build was declined. +5. **A first deployment happens.** CLAUDE.md §0's beta framing is a stated fact with a stated + expiry. Every conditional in §1.7 becomes a present-tense claim the day an adopter goes live, and + this ADR must be re-read before that. + +## 7. ASVS linkage + +**Requirement: ASVS 12.1.4 (L3), certificate revocation.** A scorecard cell for 12.1.4 may cite this +ADR as the reasoning for both graded directions. The vocabulary CLAUDE.md fixes applies: the subject +is the engine, the record lives elsewhere, and this ADR is neither. + +What a cell may take from here: + +- **Direction 1** is **runtime-blocked**, evidenced by the §1.2 probe with its positive controls, not + by an absence of code. +- **Direction 2** is a **delegated residual with an enforced construction-time refusal**, evidenced at + `config/tls_policy.py:963-973` and `:1040`, wired at the seven sites in §1.5 rider 1. +- **Direction 3** is **present and must not be counted toward either**, evidenced at + `config/tls_policy.py:215-276` with exactly three call sites, all on `PROTOCOL_TLS_SERVER` contexts. +- **Three originating hops carry no guard**, evidenced at `transports/smart.py:183-184`, + `auth/oidc_http.py:99-101`, `logging_setup.py:310`; the build rider is §4.3. + +Anchor these to code, not to this ADR's line numbers. **This document is prose and will be edited; +the code is the evidence.** Nothing in this section is scorecard content, and nothing from the +scorecard is reproduced here. + +## 8. Sources that are stale, and must not be cited as current + +Recorded so the next reader does not re-derive a refuted premise, and so nobody edits these files as +a side effect of reading this ADR. + +- **`docs/adr/0078-certificate-revocation-posture.md:30`** — "A grep across the whole tree confirms + there is **zero OCSP/CRL code anywhere**." **False at `origin/main`.** `harden_crl_check` is at + `config/tls_policy.py:215` on `origin/main` at `06ef8ec8`, with three call sites. That premise was + true when ADR 0078 was accepted (2026-07-10) and the #1005 build retired it. ADR 0078's *decision* + stands; only that one premise line is stale. Correcting it is an ADR 0078 amendment, not an edit + made in passing. +- **`docs/BACKLOG.md:231`** (repeated verbatim at `:3889`) — "No client-certificate revocation code + exists anywhere under `messagefoundry/`". **False at `origin/main`**, same evidence. + **DO NOT edit `docs/BACKLOG.md` from this ADR.** The item's own status is the backlog's business. + +## 9. Not addressed + +- **Whether any partner actually consumes a stapled response.** That is a question about someone + else's software and the engine gets no signal about it. The accept in §2 does not depend on the + answer; the trigger in §6 rider 3 is what would surface one. +- **The `truststore`-backed hops** (`apiclient/client.py:222`, `tray/probe.py:123`). Chain building + goes to the OS verifier, which may or may not consult revocation depending on the host. **Neither + half of that is asserted here**, because engine code neither configures nor observes it. +- **`enum_crls`.** Windows-only, returns raw CRL bytes from the OS certificate store, and has zero + call sites in `messagefoundry/`. Whether it could back a Windows-specific path is not evaluated. +- **A count of "verifying hops".** §1.3's table lists thirteen originating sites and §4.3 explains why + a fixed count is a liability. Read it as "at least these", per SDS-3.6. diff --git a/docs/adr/README.md b/docs/adr/README.md index 099dc4ca..55421f6c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -194,3 +194,4 @@ what is withheld and what you can request. | [0169](0169-username-identity-is-case-sensitive-and-must-not-depend-on-store-collation.md) | **Username identity is case-sensitive, and no identity decision may depend on store collation** (BACKLOG #1268) -- `users.username` was the one identifier column in the SQL Server schema with no `COLLATE` clause, so it inherited the DATABASE default (case-INsensitive on a stock install) while every sibling identifier column in the same file pinned `Latin1_General_100_BIN2` and both other backends were case-SENSITIVE -- `Admin` and `admin` two accounts on two backends and one on the third, under a `UNIQUE` constraint that reads as if it had settled the question. That portability defect became a SECURITY defect because a second site answered the same question by a different rule: `_login_local` gated WP-3 bootstrap expiry/supersession enforcement on a PYTHON `username == BOOTSTRAP_USERNAME` against the caller's input, while the lookup one line below was resolved by the COLUMN'S collation. The two disagree in exactly one direction -- `Admin` FAILS the Python guard so retirement never runs, then SUCCEEDS at the lookup and returns the very row the skipped call would have disabled. MEASURED on a lapsed unclaimed bootstrap with 6.4.1 disarmed so it could not mask the result: `login("admin")` refused and retired, `login("Admin")` returned ok=True with a session issued and `disabled` unset -- SDS-3.7 exactly, a compensating control resting on the false premise that the username the gate compared is the username the store matched. Decision, two rules: usernames ARE case-sensitive (the column now pins the collation its own file's convention already required), and **no identity decision may be delegated to store collation** -- the gate compares the value THE STORE RETURNED, never the caller's input, then re-reads by id since retirement may have disabled the row. The second rule is load-bearing and does NOT depend on the first: it stays correct under a collation the engine does not control (operator-supplied database, restored dump, column altered downstream), where limb 1 alone leaves the gate one `ALTER COLUMN` from being wrong again with nothing reporting it. Cost is one extra lookup ON THE BOOTSTRAP PATH ONLY, so the original guard's stated intent (normal logins free of extra lookups) is preserved rather than traded. Rejected: case-INsensitive normalisation (requires a canonicalisation that is not locale-neutral -- the Turkish dotless `i` -- so a wrong fold silently MERGES two accounts under a UNIQUE constraint that enforces rather than catches it; and it would have to hold across three backends plus the audit trail, every one a fresh place for the two rules to diverge again); column-only (makes the gate accidentally correct, contingent on a schema the engine stops controlling); gate-only (closes the security defect, leaves identity store-dependent). Existing SQL Server databases keep their original collation -- the DDL is creation-guarded and no re-type is attempted; zero deployments (CLAUDE.md section 0) so there is nothing to migrate, recorded so the schema-hash bump is not misread as a column alteration. Flagged undecided: two accounts differing only in case are themselves a confusability risk, closeable additively by a registration-time refusal without reopening this decision. Verification carries its own retraction -- the first version of both gate tests PASSED against unfixed code because they used the supersession arm, which `create_local_user` retires eagerly at `service.py:2685`, so the account was already disabled before the login ran; only the EXPIRY arm reaches the login path with retirement still pending | Proposed (2026-08-20) -- written in the conditional; **zero deployments**, so this is what a first deployment against a SQL Server store would hit, not a live exposure | | [0170](0170-constant-work-recovery-code-verification-pad-to-the-configured-slot-count-rather-than-short-circuit.md) | **Constant-work recovery-code verification: pad to the configured slot count rather than short-circuit** (BACKLOG #1167, ASVS 11.2.4) -- `_verify_second_factor` walked the argon2id recovery-code hashes and `return`ed on the first match, so the NUMBER of ~64 MiB verifications was a function of which code was presented. **Two leaks and only one matters:** the matched INDEX is worthless (the attacker holds the code and the response answers them anyway), but on the FAILURE path the cost is one verify per REMAINING code -- so anyone holding the password can time a wrong-code refusal and learn how many recovery codes an account has left, without authenticating to the second factor. **The item rated this difficulty 7 on a premise that does not survive measurement:** the re-score says a constant loop 'converts a timing leak into a memory and CPU amplification target', which is the right objection to raise -- and the failure path ALREADY verifies every remaining hash, so making the walk unconditional introduces no new cost, it makes today's WORST CASE the only case. Decision: always run exactly `mfa_recovery_code_count` verifies, padding with the same fixed `_DUMMY_PASSWORD_HASH` the local login leg uses, and select the winner AFTER the loop. Ceiling unmoved (default 10, validator-capped 50); `_argon2`'s semaphore means the concurrent-argon2 footprint cannot widen either; and the path sits behind primary authentication, so it is not an unauthenticated flood surface. **Claims constant WORK, not constant TIME** -- the store round trip on a match is not equalized, the TOTP branch returns earlier, and argon2's own constant-timeness is INHERITED from `argon2-cffi` and has never been measured in this tree, a gap #1167 names and this does not close. No timing measurement was run by the item or by this change. Rejected: leaving the short-circuit as accepted (the fix cost nothing against the existing ceiling, so 'accepted' would have been a judgement made before the amplification premise was checked); and a non-secret lookup index so only ONE verify ever runs -- strictly better on both axes, rejected as OUT OF SCOPE rather than wrong, needing a schema change across three backends and a migration, and recorded so it is not re-derived if the constant walk's cost ever bites | **Accepted (2026-08-22)** -- built with the change. Three parametrized tests pin the count for a first-slot match, a last-slot match and a non-match; proven red-first, removing the padding reds ALL THREE and the file restores byte-identical by SHA-256. Severity conditional per CLAUDE.md section 0 -- **zero deployments**, so this is what a first deployment would have inherited | | [0171](0171-offline-administrator-unlock-a-host-gated-cli-recovery-path-for-a-sole-administrator-lockout.md) | **Offline administrator unlock: a host-gated CLI recovery path for a sole-administrator lockout** (BACKLOG #1236) -- a deployment with ONE administrator had no recovery from account lockout, and every exit is individually deliberate: the bootstrap account is literally `admin`, it is created with no email so the ACCOUNT_LOCKED notice never leaves the process, self-reset is refused, an admin reset needs ANOTHER admin, re-bootstrap fires only on an EMPTY users table, and none of 38 CLI subcommands managed users. **The defect is that they close SIMULTANEOUSLY for that deployment** and nothing notices the conjunction. **The filed acceptance criterion could not discriminate and was amended 2026-08-21:** "recover without hand-editing the database and without a second admin" PASSES ON THE SHIPPED SYSTEM BY WAITING, since the lock self-expires after `lockout_minutes`; a test both a fixed and a broken system pass is not a test. Decision: `messagefoundry admin-unlock --username `. **The gate is HOST ACCESS and it is a real gate rather than an absent one** -- reaching it needs the config, the store path and on an encrypted store the key material, so anyone holding all three already has the database and does not need an unlock to reach an account; that is why it ships unauthenticated, and it is the load-bearing claim. **Clears the lockout and does NOT reset the password** -- deliberately narrower, since a reset would hand the runner a working account. **Reuses `record_login_failure(failed_attempts=0, locked_until=None)` rather than adding a protocol method**, decided by a MEASURED cross-lane fact rather than taste: a named `clear_lockout` would touch base/store/postgres/sqlserver, and all four were uncommitted in a peer lane at the time, so reuse avoided a four-file collision. Exit codes follow the `--json` convention (`_emit_error`, 1) not the M-31 lineage (stderr, 2), verified against `audit-verify` which has no `--json` flag. Carries M-31 forward: a typo'd `--db` is refused rather than creating an empty SQLite store and reporting a false "no such account" | **Accepted (2026-08-22)** -- built with the change. Four tests; **exactly ONE is the control** and the other three are deliberately insensitive -- neutering the clearing call reds only the acceptance test, and the audit-row test still passes under that plant, so it evidences the flow RAN and never that it WORKED. Does NOT address #1236's repetition limb: lock cycles remain unbounded and an attacker can re-lock. Severity conditional per CLAUDE.md section 0 -- **zero deployments** | +| [0173](0173-tls-peer-revocation-checking-and-ocsp-stapling-across-terminating-and-originating-surfaces.md) | **TLS peer revocation checking and OCSP stapling across terminating and originating surfaces** (BACKLOG #1005, ASVS 12.1.4) -- the requirement reaches in two directions and the engine answers neither: where the product TERMINATES TLS it does not staple its own certificate's status, and where it ORIGINATES it does not check the peer's revocation. Direction 1 is RUNTIME-BLOCKED rather than unbuilt -- CPython 3.14.6 exposes no stapling surface at all, measured against live positive controls, so no amount of engineering here reaches it. The opt-in client-certificate CRL checking that DOES ship (`config/tls_policy.py:215-276`, three PROTOCOL_TLS_SERVER call sites) is a THIRD combination -- peer revocation on the terminating side -- and moves neither graded direction; that is the single easiest thing in this area to misread. DECISION: accept and document both directions, with one build rider the accept reasoning does not cover -- three originating hops that never reach the existing revocation guard, filed by subject and deliberately unallocated. | **Proposed (2026-08-23)** -- no code changed. Severity is conditional per CLAUDE.md section 0: on a first deployment a revoked partner certificate would keep verifying on the unguarded hops; there are zero deployments today. Five citation errors from the adversarial pass were repaired before filing. | From f85bf1e7e3fe5cad466bc579fa19e16fdd2d3d99 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 23 Aug 2026 16:11:43 -0500 Subject: [PATCH 07/10] adr(0173): withdraw a rider hop that is not a TLS site, and state the population is ungraded The rider named three unguarded originating hops. One of them does not exist as a TLS site: messagefoundry/transports/smart.py contains ZERO ssl usage -- no create_default_context, no SSLContext, no ssl. anywhere -- against a control of 153 such hits package-wide. Lines 183-184 are an opener and a redirect handler; the file rides urllib's own context. Found by the Dispatcher while scoping the rider for a build lane, checked before queuing rather than after, and re-verified here independently. TWO CONSEQUENCES, and the second matters more than the first: The two remaining hops are confirmed exactly as written -- auth/oidc_http.py:99-101 and logging_setup.py:310 are both real create_default_context calls. "Three" also understated the population. At least six further unguarded context constructions exist (apiclient/client.py:214, postgres.py:729, rest.py:275, :296, soap.py:202, tls_policy.py:1218) and NONE has been graded -- several are outbound client contexts where the answer may differ. The rider is therefore not a well-specified three-hop job; it is an ungraded population. Scoping it as small would have understated the work. This citation survived four adversarial refuters, eight hand repairs and an owner ruling. It is the same class as the SMTP if/else already corrected here: a citation that reads correct and points at the wrong construct. What the repair pass checked was whether cited lines say what the ADR claims -- not whether the cited FILE is the kind of thing being claimed about. Co-Authored-By: Claude Opus 5 --- ...-stapling-across-terminating-and-originating-surfaces.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/0173-tls-peer-revocation-checking-and-ocsp-stapling-across-terminating-and-originating-surfaces.md b/docs/adr/0173-tls-peer-revocation-checking-and-ocsp-stapling-across-terminating-and-originating-surfaces.md index a3b6ebfd..5539aae1 100644 --- a/docs/adr/0173-tls-peer-revocation-checking-and-ocsp-stapling-across-terminating-and-originating-surfaces.md +++ b/docs/adr/0173-tls-peer-revocation-checking-and-ocsp-stapling-across-terminating-and-originating-surfaces.md @@ -109,7 +109,7 @@ not revocation checking**"* (`:202`). | Engine-to-store (asyncpg) | `store/postgres.py:752`, and `:760` where asyncpg builds it (`:729` is the `trust_server_certificate` escape, `CERT_NONE` at `:731`) | the whole PHI store | | Syslog / SIEM forwarder (RFC 5425) | `logging_setup.py:310` | audit records | | OIDC / IdP token and JWKS legs | `auth/oidc_http.py:99-101` | the client secret, the authorization code | -| SMART Backend Services token endpoint | `transports/smart.py:183-184` | a signed `client_assertion`, a bearer token | +| SMART Backend Services token endpoint | **NOT A TLS SITE -- row withdrawn 2026-08-23.** `transports/smart.py` contains ZERO `ssl` usage (`create_default_context`, `SSLContext`, `ssl.` all absent; control: 153 hits package-wide). `:183-184` is an opener and redirect handler. It rides urllib's own context. | -- | | Shared engine-API client | `apiclient/client.py:214`, `:222` | session credentials | | Windows tray probe | `tray/probe.py:123` | health only | | TLS version prober | `config/tls_probe.py:97` | nothing; it scopes itself out at `:32` | @@ -198,7 +198,7 @@ of the three carry authentication material: | Hop | Site | Crosses with | |---|---|---| -| SMART Backend Services token endpoint | `transports/smart.py:183-184` | a signed `client_assertion` JWT out, a bearer token back | +| SMART Backend Services token endpoint | **NOT A TLS SITE -- see the withdrawal in §1.3.** No `ssl` usage in the file at all. | -- | | OIDC / IdP token and JWKS legs | `auth/oidc_http.py:99-101` | the client secret, the authorization code, the identity assertion (its own comment, `:105-107`) | | Syslog / SIEM TLS forwarder | `logging_setup.py:310` | audit records; no `harden_verify_flags` either -- shared with the other two unguarded hops, not peculiar to this one | @@ -460,7 +460,7 @@ What a cell may take from here: `config/tls_policy.py:963-973` and `:1040`, wired at the seven sites in §1.5 rider 1. - **Direction 3** is **present and must not be counted toward either**, evidenced at `config/tls_policy.py:215-276` with exactly three call sites, all on `PROTOCOL_TLS_SERVER` contexts. -- **Three originating hops carry no guard**, evidenced at `transports/smart.py:183-184`, +- **The unguarded-hop population is LARGER THAN TWO AND UNGRADED** -- corrected 2026-08-23. Two hops are confirmed and evidenced below. A third citation, `transports/smart.py:183-184`, is **WITHDRAWN: that file has no `ssl` usage at all** and cannot carry a guard. Separately, at least six further unguarded context constructions exist (`apiclient/client.py:214`, `store/postgres.py:729`, `rest.py:275`, `:296`, `soap.py:202`, `tls_policy.py:1218`) and **NONE of them has been graded** -- several are outbound client contexts where the answer may differ. **Do not scope this rider as a well-specified three-hop job.** The confirmed two are at `auth/oidc_http.py:99-101`, `logging_setup.py:310`; the build rider is §4.3. Anchor these to code, not to this ADR's line numbers. **This document is prose and will be edited; From 1b05cb159933bcee42bdfe8dd16c4fede59f1aca Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 23 Aug 2026 16:19:38 -0500 Subject: [PATCH 08/10] adr(0173): state the hop count once, in one place, instead of six times in five voices The ninth repair withdrew a rider hop that is not a TLS site and stated the population is larger than two and ungraded. It corrected ONE of the six places the ADR asserted that count. The other five still said "three", so the document contradicted itself -- and the worst of them, section 4.3's harden_verify_flags measurement, re-cited transports/smart.py as a measured member of the population immediately after the withdrawal. Found by the Lander, who held the commit rather than landing it. FIXED BY REMOVING THE RESTATEMENTS, not by editing the number into five more places. CLAUDE.md section 11, SDS-3.5: state a load-bearing fact ONCE and link to it. The count now lives in section 4.3 alone; the other five sites say "the unguarded hops" and point there. A fact restated six times gets corrected in one. :196 now "TWO CONFIRMED ... population larger and UNGRADED, see 4.3" :237 count and scope delegated to 4.3 :360 smart.py REMOVED from the measured list, with the reason :373 "three" dropped :383 points at 4.3 THE THIRD LAYER, and the Lander named it exactly. Nine repairs fixed CITATIONS: lines pointing at the wrong construct. This is the COUNT those citations supported, restated across the document. Four refuters, nine repairs and an owner ruling each stopped one layer short of the next -- the refuters checked whether cited lines say what is claimed, the ninth repair asked whether the cited FILE is the kind of thing being claimed about, and neither asked whether the CLAIMS DEPENDING ON THE CITATION were updated with it. No claim widened. The six further unguarded context constructions remain ungraded and this commit does not grade them. Co-Authored-By: Claude Opus 5 --- ...ling-across-terminating-and-originating-surfaces.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/adr/0173-tls-peer-revocation-checking-and-ocsp-stapling-across-terminating-and-originating-surfaces.md b/docs/adr/0173-tls-peer-revocation-checking-and-ocsp-stapling-across-terminating-and-originating-surfaces.md index 5539aae1..bc3ac619 100644 --- a/docs/adr/0173-tls-peer-revocation-checking-and-ocsp-stapling-across-terminating-and-originating-surfaces.md +++ b/docs/adr/0173-tls-peer-revocation-checking-and-ocsp-stapling-across-terminating-and-originating-surfaces.md @@ -193,7 +193,7 @@ control on a premise that is false for that transport. ### 1.6 Three hops cross with no signal at all -Three originating hops have **no guard of any kind** — no refusal, no warning, no audit line — and two +**TWO CONFIRMED originating hops** have **no guard of any kind** — no refusal, no warning, no audit line — **and the true population is larger and UNGRADED; the count and its correction live in §4.3 and nowhere else.** Two of the three carry authentication material: | Hop | Site | Crosses with | @@ -234,7 +234,7 @@ one build rider that the accept reasoning does not reach.** - **Direction 3 (the shipped opt-in client-certificate CRL): unchanged, and it does not count toward either graded direction.** It stays opt-in and default-off. The record must not cite it as partial 12.1.4 coverage. -- **The three unguarded hops in §1.6 are NOT accepted.** Accepting the *mechanism* is not accepting +- **The unguarded hops in §1.6 are NOT accepted** (count and scope: §4.3). Accepting the *mechanism* is not accepting *silence*. Closing them needs no new dependency, no new control and no stapling, so the reasoning that justifies the rest does not apply to them. See §4.3. @@ -357,7 +357,7 @@ The three hops at §1.6 get the same posture-keyed treatment their siblings alre exists and the call is one statement: `refuse_unrevoked_verified_hop(scheme, url, connector=, revocation_attested=)` at `transports/rest.py:656-679`, called in exactly that shape by five siblings (`fhir.py:370`, `dicomweb.py:261`, `rest.py:1347`, `soap.py:403`, and `email.py:239` via -`RevocationHopGuard.capture`). **All three unguarded hops also lack `harden_verify_flags`** -- measured at HEAD, it occurs zero times in `logging_setup.py`, `auth/oidc_http.py`, `transports/smart.py` and `transports/rest.py` alike, against a positive control of seven files that do carry it. An earlier draft of this ADR presented the omission as peculiar to syslog; it is not, and the corrected reading strengthens the rider rather than weakening it -- the gap spans the group. +`RevocationHopGuard.capture`). **The unguarded hops also lack `harden_verify_flags`** -- measured at HEAD, it occurs zero times in `logging_setup.py`, `auth/oidc_http.py` and `transports/rest.py` alike, against a positive control of seven files that do carry it. (`transports/smart.py` was in this measured list and is REMOVED: it has no `ssl` usage at all, so it is not a member of the population -- see the withdrawal in §4.3.) An earlier draft of this ADR presented the omission as peculiar to syslog; it is not, and the corrected reading strengthens the rider rather than weakening it -- the gap spans the group. **Filed by subject, deliberately unallocated** (CLAUDE.md §"Never CITE a `#N` you have not allocated"): *revocation-guard parity for the SMART token endpoint, the OIDC token and JWKS legs, and @@ -370,7 +370,7 @@ record: `transports/direct.py:250-253` shows, in a shipped comment, that DIRECT `RevocationHopGuard` partly because adding it "would make the enumerated count eight and force four 'seven verifying hops' docs to change". That comment gives a real second reason and records the choice honestly, which is the right instinct. But SDS-3.6 says a completeness claim is a liability, -and an accept-and-document ruling that left three unguarded credential hops outside the count would +and an accept-and-document ruling that left the unguarded credential hops outside the count would write a number a reader treats as coverage. ## 5. Consequences @@ -380,7 +380,7 @@ write a number a reader treats as coverage. - The 12.1.4 record stops oscillating between "unbuilt" and "impossible" for direction 1. The runtime probe in §1.2 is reproducible in under a second, with no engine tree, corpus or network. - Direction 3 stops being miscounted as partial coverage of directions 1 or 2. -- The three unguarded credential hops are named and owned instead of being invisible inside an +- The unguarded credential hops are named and owned (§4.3) instead of being invisible inside an enumeration. - Nothing in the tree claims a revocation check it does not perform. The refusals name the missing check and the two ways to prove it in front (`tls_policy.py:1033-1037`, `__main__.py:1729-1736`). From 0d1b6fd2600ea777bf033f828a8cd5ac31d664a4 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 23 Aug 2026 18:09:40 -0500 Subject: [PATCH 09/10] backlog: record the #1350-#1352 research, including the one verdict that refutes its own researcher Owner-directed research ran as a 10-agent dispatcher workflow with two refuters per finding. The rows are written here rather than by the dispatcher because ledger_check.py takes base='origin/main' as a default argument: #1350-#1352 are on PR 553, not on main, so from any worktree they read as new headings and owns() pins them to this one. Measured by the dispatcher, not assumed. #1350 ASVS 1.3.4 -- cannot honestly pass without work. Neither refuter broke it. The browser-active classifier is a FOUR-TOKEN DENY-LIST (html, xml, script, svg). Reproduced here: application/hta is not flagged, and mimetypes.guess_extension("application/hta") returns .hta FROM THE WINDOWS REGISTRY, so the served extension is host-derived. AND IT CORRECTS THIS ITEM'S OWN PREMISE, which was mine: the item asked whether the sandbox holds on every path, assuming the CSP is reached THROUGH the classifier. It is not. _ATTACHMENT_CSP is re-asserted on every attachment response (app.py:720, :749) and set directly at :3571; Content-Disposition is unconditional. The classifier downgrades the declared Content-Type; it does not gate the sandbox. #1351 ASVS 10.4.13 -- THE RESEARCH VERDICT DOES NOT HOLD. Engine facts right, disposition wrong, and one refuter broke it. APPLICABILITY WAS NEVER ASKED: ASVS-ASSESSMENT-METHOD.md:44-45 makes scope rule 1, ahead of every control question. V10.4 is the OAuth AUTHORIZATION SERVER section and this engine hosts none. The item text skipped that rule too, so this is a defect in the row as much as in the research. The honest next step is reading the 10.4.13 text and applying rule 1 -- not building PAR against a requirement that may not apply. #1352 ASVS 11.4.4 -- cannot honestly pass without work. Both refuters reproduced the control. ALL FIVE PINNED ARGON2ID VALUES ARE BYTE-IDENTICAL TO argon2-cffi 25.1.0's DEFAULTS, verified here against the installed library, under a comment at passwords.py:14 stating they are pinned explicitly RATHER THAN relying on those defaults. The values meet OWASP guidance; what is missing is any measurement behind them. A refuter timed the heavy profile at 696.5ms against 34-36ms shipped. Every load-bearing engine claim above was re-verified in this worktree rather than taken from the findings file. Co-Authored-By: Claude Opus 5 --- docs/BACKLOG.md | 89 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 6e409d93..6b6c5526 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -15926,6 +15926,38 @@ writer refuses any ref that is not its branch's tip. **Source:** filed 2026-08-23 by the ASVS-TRACKER seat, owner-directed. The 2026-08-08 ledger-coverage sweep that produced this family of items scanned the **partial and fail** cells and did not cover **needs-review**, so all three contested cells were absent from the ledger while 186 of their siblings carried an item. That is a gap in the sweep's domain, not a lapse in anyone's diligence -- and it is why a cell can stay contested for three weeks with nothing scheduling it. +**RESEARCHED 2026-08-23** (dispatcher workflow `wf_8ae5d086-a15`, 10 agents, each finding attacked +by two refuters; findings file `DISPATCHER-2026-08-23-HANDOFF-FINDINGS-asvs-1350-1352-research.md`). +**Verdict: CANNOT HONESTLY PASS WITHOUT WORK. Neither refuter broke it.** + +**CORRECTION TO THIS ITEM'S OWN PREMISE, and it is mine.** The text above asks whether the sandbox +"holds for every response path or only the download route", which assumes the CSP is reached +*through* the classifier. It is not. `_ATTACHMENT_CSP` is re-asserted on **every** attachment +response by middleware (`api/app.py:720`, `:749`) and set directly at `:3571`, and +`Content-Disposition: attachment` is unconditional. **The classifier does not gate the sandbox -- +it downgrades the declared `Content-Type`.** So sub-question (2) as written is answered "every +path", and the real question is narrower: what an active type that the classifier does not +recognise is *labelled* as while being served under the sandbox. + +**The decisive measurement, reproduced by this seat.** `_BROWSER_ACTIVE_SUBTYPE_TOKENS` is a +**FOUR-TOKEN DENY-LIST** -- `html`, `xml`, `script`, `svg` -- plus a `multipart` top-type rule. +Verified against the shipped source: `application/hta` is **NOT** flagged browser-active, and +`mimetypes.guess_extension("application/hta")` returns `.hta` **from the Windows registry**, so the +served extension is **host-derived**. A refuter found six further pass-through types. + +**Work that would reach an honest pass**, in the findings' order: +1. **Invert the classifier to a short ALLOW-LIST.** Completeness then becomes a property of a + reviewable list rather than an unprovable negative. +2. **Stop calling `mimetypes.guess_extension`.** Map the extension from the same allow-list, + default `.bin`. A host-derived extension is not a property of the product. +3. **Decide the PDF question in writing.** A genuine PDF passes both the token test and the magic + check, and PDF JavaScript runs in the viewer once a saved file is opened. + +**STILL UNMEASURED, and the findings say so: no browser was exercised by anyone.** Three claims +rest on specification alone, including that `Content-Disposition: attachment` suppresses inline +rendering. That is the gap a pass would have to close with a real user agent. + + ## 1352. research an honest pass for ASVS 11.4.4 -- key-derivation parameters that balance security against brute force, at every derivation site > 🔢 **Filed 2026-08-23 - not started. RESEARCH item: the goal is an HONEST pass, and "cannot honestly reach pass" is a valid finding.** ASVS **11.4.4** (L2) is one of the three cells the assessment record holds as CONTESTED between assessors rather than merely unbuilt, so the blocker is a reading to be settled before any build is scoped. Value **5/10** - Difficulty **3/10** - _fill-in_. @@ -15945,6 +15977,37 @@ writer refuses any ref that is not its branch's tip. **Source:** filed 2026-08-23 by the ASVS-TRACKER seat, owner-directed. The 2026-08-08 ledger-coverage sweep that produced this family of items scanned the **partial and fail** cells and did not cover **needs-review**, so all three contested cells were absent from the ledger while 186 of their siblings carried an item. That is a gap in the sweep's domain, not a lapse in anyone's diligence -- and it is why a cell can stay contested for three weeks with nothing scheduling it. +**RESEARCHED 2026-08-23** (same workflow and findings file as #1350). **Verdict: CANNOT HONESTLY +PASS WITHOUT WORK. Both refuters reproduced the decisive control and neither refuted it.** + +***THE DECISIVE FINDING, REPRODUCED INDEPENDENTLY BY THIS SEAT: ALL FIVE PINNED VALUES ARE +BYTE-IDENTICAL TO THE LIBRARY DEFAULTS THEY SAY THEY ARE NOT.*** + + engine, auth/passwords.py:19-23 t=3 m=65536 p=4 hash_len=32 salt_len=16 + argon2-cffi 25.1.0 PasswordHasher t=3 m=65536 p=4 hash_len=32 salt_len=16 + +The comment directly above them (`auth/passwords.py:14`) reads *"pinned EXPLICITLY rather than +relying on argon2-cffi's library defaults"*. **Every value equals the default.** The values are not +wrong -- they meet OWASP guidance -- but *pinned deliberately* and *inherited* are indistinguishable +in the artifact, which is exactly what this item said would not be an honest pass. + +**No measurement backs them.** No target hardware, no login-latency budget, no rejected alternative +appears anywhere. The only recorded reason is a security floor. A refuter timed the heavy profile at +**696.5ms** against the shipped **34-36ms**, so a cost/latency trade-off is measurable and was never +measured. + +**Work that would reach an honest pass -- small, and it is WRITING, not code:** +1. **Record the choice where the parameters live**: reference hardware, measured per-verify cost, + the login concurrency the engine must sustain, and why the heavier profile was declined. +2. **Resolve the defaults coincidence explicitly.** Leaving `passwords.py:14` as written is worse + than silence, because it asserts a deliberation the values cannot evidence. +3. **Cover the recovery-code multiplier**: `auth/service.py:2323` runs `mfa_recovery_code_count` + verifies per attempt, default 10 and capped at 50. + +**STILL UNMEASURED:** whether the second sentence this item quotes is actually part of ASVS 11.4.4. +**If it is not, the first question alone is a clean pass** -- so that check comes before the work. + + ## 1351. research an honest pass for ASVS 10.4.13 -- whether the OIDC authorization request must leave the browser-visible front channel > 🔢 **Filed 2026-08-23 - not started. RESEARCH item: the goal is an HONEST pass, and "cannot honestly reach pass" is a valid finding.** ASVS **10.4.13** (L3) is one of the three cells the assessment record holds as CONTESTED between assessors rather than merely unbuilt, so the blocker is a reading to be settled before any build is scoped. Value **4/10** - Difficulty **3/10** - _fill-in_. @@ -15963,3 +16026,29 @@ writer refuses any ref that is not its branch's tip. **What would NOT be an honest pass.** Treating PKCE as a general answer to a requirement about request confidentiality. PKCE binds an authorization code to the client that requested it; it does not conceal or integrity-protect request parameters travelling through the user agent. If the pinned text asks for the latter, PKCE is the wrong control cited for a right-sounding reason. Equally: restoring not-applicable because no in-scope identity provider supports PAR -- deployment availability is a difficulty input, not a scope argument. **Source:** filed 2026-08-23 by the ASVS-TRACKER seat, owner-directed. The 2026-08-08 ledger-coverage sweep that produced this family of items scanned the **partial and fail** cells and did not cover **needs-review**, so all three contested cells were absent from the ledger while 186 of their siblings carried an item. That is a gap in the sweep's domain, not a lapse in anyone's diligence -- and it is why a cell can stay contested for three weeks with nothing scheduling it. + +**RESEARCHED 2026-08-23** (same workflow and findings file as #1350). ***THE RESEARCH VERDICT DOES +NOT HOLD, AND THE REFUTER WHO BROKE IT WAS RIGHT.*** The engine observations are accurate; the +disposition -- "the relying party must build PAR" -- does not follow. + +**WHY IT BREAKS, and the first reason is this item's fault as much as the researcher's.** +**APPLICABILITY WAS NEVER ASKED.** `docs/ASVS-ASSESSMENT-METHOD.md:44-45` makes *"does the +requirement apply on the declared scope"* **rule 1, ahead of every control question**. V10.4 is the +**OAuth AUTHORIZATION SERVER** section, and **this engine hosts none** -- it is a relying party. +The item text above jumps straight to "does the pinned text require PAR", which is a control +question. It skipped the rule that comes first. + +Second, the finding admits it **never read the 10.4.13 text** and reasoned from a paraphrase -- +which `ASVS-ASSESSMENT-METHOD.md:36` defines as unverified. Third, it carried a **false cost +claim** about client-side signing keys, caught independently by both refuters. + +**WHAT REACHES AN HONEST DISPOSITION: NOT CODE.** Read the ASVS 5.0.0 text of 10.4.13 at a known +commit and apply rule 1. If V10.4 governs the authorization server, the cell is very likely `na` +on scope and no build is owed. **Building PAR before asking that question would be engineering +against a requirement that may not apply.** + +**The narrow engineering statement survives and is worth keeping:** on a first deployment with +federation on, the authorization request would travel the browser-visible front channel, where the +identity provider is the only party positioned to reject tampering. That is true, and it is not by +itself a finding against this cell. + From a663ae900de229e8e7fed25c687fb91560fdeb20 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 23 Aug 2026 19:43:41 -0500 Subject: [PATCH 10/10] adr(0173): quote the requirement it grades, and label its own reading as one The ADR put ASVS 12.1.4 in quotation marks as "proper certificate revocation, such as OCSP stapling" and asserted that sentence "covers two independent behaviours". Both are wrong against the primary source, which the vault already tracks and which needs no network to read: asvs-5.0.0-source/OWASP_..._5.0.0_en.csv line 254 "Verify that proper certification revocation, such as Online Certificate Status Protocol (OCSP) Stapling, is enabled and configured." level 3 One sentence. No "client", no "peer", no "server". ASVS uses the same `such as` construction one row up (12.1.1, "such as TLS 1.2 and TLS 1.3") for a plainly non-exhaustive example, so the operative noun is proper certification revocation and stapling is an instance of it. The client-side duty is a DIFFERENT requirement -- 12.3.2, L2 -- and it grades validation, not revocation. So the two-direction split is this ADR's reading, not the requirement's structure. It is kept, because the engine's surfaces do differ that way and the two do have different remedies; it is now labelled an interpretation instead of being presented as the standard's wording, and section 7 tells a citing cell to carry that caveat. The verdict does not move and this commit does not move it. A contested reading resolves to the worse verdict, and reinterpreting a requirement to turn a red green is the move this same cell already declined. Whether the reasoning resting on the split gets amended is an open owner decision. Co-Authored-By: Claude Opus 5 --- ...ss-terminating-and-originating-surfaces.md | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/adr/0173-tls-peer-revocation-checking-and-ocsp-stapling-across-terminating-and-originating-surfaces.md b/docs/adr/0173-tls-peer-revocation-checking-and-ocsp-stapling-across-terminating-and-originating-surfaces.md index bc3ac619..eca9b36a 100644 --- a/docs/adr/0173-tls-peer-revocation-checking-and-ocsp-stapling-across-terminating-and-originating-surfaces.md +++ b/docs/adr/0173-tls-peer-revocation-checking-and-ocsp-stapling-across-terminating-and-originating-surfaces.md @@ -29,12 +29,31 @@ ## 1. Context -### 1.1 What ASVS 12.1.4 asks, in two directions - -The requirement asks that "proper certificate revocation, such as OCSP stapling, is enabled and -configured". That sentence covers two independent behaviours, and they have different remedies: - -| # | Direction | The engine's role | The question it answers | +### 1.1 What ASVS 12.1.4 asks, and the reading this ADR applies to it + +The requirement is **one sentence**, graded Level 3. Quoted exactly from the ASVS 5.0.0 source the +vault tracks (`docs/security/asvs-5.0.0-source/OWASP_Application_Security_Verification_Standard_5.0.0_en.csv`, +line 254): + +> Verify that proper certification revocation, such as Online Certificate Status Protocol (OCSP) +> Stapling, is enabled and configured. + +**An earlier revision of this ADR misquoted that sentence** -- as "proper *certificate* revocation, +such as OCSP stapling" -- and asserted it "covers two independent behaviours". Corrected 2026-08-24 +against the primary source. Two things follow, and **neither moves the verdict**: + +* **The sentence names one behaviour and one example.** It contains no "client", no "peer" and no + "server". ASVS uses the same `such as` construction one row up (12.1.1, "such as TLS 1.2 and TLS + 1.3") for a plainly non-exhaustive example, which is internal evidence that the operative noun is + *proper certification revocation*. The client-side duty lives in a **different** requirement -- + 12.3.2, Level 2 -- and that one is about *validation*, not revocation. +* **So the split into directions below is THIS ADR's reading, not the requirement's structure.** It + is kept, because it is how the engine's surfaces genuinely differ and the two have different + remedies. It is now labelled an interpretation rather than presented as the standard's wording. + Whether the reasoning resting on it is amended is an **open owner decision**; the `fail` verdict + stands either way, because a contested reading resolves to the worse verdict. + +| # | Direction (this ADR's axes, not the requirement's) | The engine's role | The question it answers | |---|---|---|---| | **1** | **Staple my own status** | **terminating** (the engine is the TLS server) | can a client learn, from my handshake, that my certificate is still good, without fetching from my issuer? | | **2** | **Check the peer's status** | **originating** (the engine is the TLS client) | is the certificate this partner just presented revoked? | @@ -448,9 +467,11 @@ names a fact that is checkable. ## 7. ASVS linkage -**Requirement: ASVS 12.1.4 (L3), certificate revocation.** A scorecard cell for 12.1.4 may cite this -ADR as the reasoning for both graded directions. The vocabulary CLAUDE.md fixes applies: the subject -is the engine, the record lives elsewhere, and this ADR is neither. +**Requirement: ASVS 12.1.4 (L3), *certification* revocation** -- the standard's noun, quoted in full +at §1.1. A scorecard cell for 12.1.4 may cite this ADR as the reasoning for both graded directions, +provided it carries §1.1's caveat that those directions are this ADR's axes rather than the +requirement's. The vocabulary CLAUDE.md fixes applies: the subject is the engine, the record lives +elsewhere, and this ADR is neither. What a cell may take from here: