diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 9d0e5b9e..79d385d9 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -166,11 +166,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**:3 · **2**:10 · **3**:19 · **4**:14 · **5**:20 · **6**:26 · **7**:8 · **8**:2. Difficulty: **1**:5 · **2**:23 · **3**:34 · **4**:21 · **5**:5 · **6**:9 · **7**:2 · **8**:2 · **9**:1. -Tiers: **P1** 9 · **P2** 22 · **P3** 18 · **DEMAND-GATE** 53. -Quadrants: _quick win_ 31 · _big bet_ 5 · _fill-in_ 57 · _money pit_ 9. +Value: **1**:3 · **2**:9 · **3**:19 · **4**:15 · **5**:21 · **6**:28 · **7**:8 · **8**:2. Difficulty: **1**:4 · **2**:22 · **3**:36 · **4**:23 · **5**:6 · **6**:9 · **7**:2 · **8**:2 · **9**:1. +Tiers: **P1** 9 · **P2** 23 · **P3** 20 · **DEMAND-GATE** 53. +Quadrants: _quick win_ 33 · _big bet_ 5 · _fill-in_ 58 · _money pit_ 9. -*All four lines sum to 102, the open-item count. They are recomputed with the table, +*All four lines sum to 105, 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). @@ -184,7 +184,7 @@ Ordered by value descending, then difficulty ascending (cheapest first at equal | 5 | **#1000** | Prove each required merge context can fail: negative controls for the gates that block merge | 7 | 3 | _quick win_ | P1 | Thirteen contexts are the entire merge gate and not one is proven able to go red, a class that has fired at least four times here (#334, #327, #321, #325) with no CI signal and each caught by hand; the build is a negative-control fixture per context plus a job that fails when one has none, no new dependency and no change to what the gates check. | | 6 | **#1010** | No licence-header gate exists in any language, and 196 first-party sources carry no SPDX tag | 7 | 3 | _quick win_ | P2 | AGPL-3.0-or-later is asserted in LICENSE and pyproject and then left to habit per file: 196 of 1,181 tracked sources across six languages carry no SPDX tag, 17 of them in a package the wheel ships, five more declare the wrong licence, and no hook, workflow or test checks a header in any language. | | 7 | **#1003** | Validate the lab and discharge the four hardware-gated residuals | 7 | 4 | _quick win_ | P2 | Four items (#99, #98, #320, #351) are parked on one missing multi-VM lab and each asserts a premise that expires when it lands, so the alternative to running them is four items telling every planning pass they are unreachable; the runs are already specified by the items they discharge and need no new design, but the lab itself has to be stood up and proven against what each residual actually requires. | -| 8 | **#1013** | The `[auth] enabled=false` startup arm keys on the bind alone, so auth-off behind a declared terminator still starts | 7 | 4 | _quick win_ | P1 | The auth-off arm (`__main__.py:1080`) tests `not settings.auth.enabled and not settings.api.is_loopback`, so it does not fire for a declared TLS-terminating proxy — a PHI instance with authentication entirely OFF behind a declared terminator starts with no refusal and no warning, while the same topology with auth ON but MFA off is refused by the gate #326 fixed. Value one above **#326** (6/3, P2): that was single-factor admin, this is no factor at all, and the two arms disagree about what "exposed" means in the same file. Difficulty 4 not 3 because the remedy is UNPROVEN — `instance_exposed` is defined 1,288 lines BELOW at `:2368`, so the arm cannot reference it without hoisting, and whether the settings it reads are resolved that early in the startup ladder is the actual work. | +| 8 | **#1013** | The `[auth] enabled=false` startup arm keys on the bind alone, so auth-off behind a declared terminator still starts | 7 | 4 | _quick win_ | P1 | The auth-off arm (`__main__.py:1112`) tests `not settings.auth.enabled and not settings.api.is_loopback`, so it does not fire for a declared TLS-terminating proxy — a PHI instance with authentication entirely OFF behind a declared terminator starts with no refusal and no warning, while the same topology with auth ON but MFA off is refused by the gate #326 fixed. Value one above **#326** (6/3, P2): that was single-factor admin, this is no factor at all, and the two arms disagree about what "exposed" means in the same file. Difficulty 4 not 3 because the remedy is UNPROVEN — `instance_exposed` is defined 805 lines BELOW at `:1917`, so the arm cannot reference it without hoisting, and whether the settings it reads are resolved that early in the startup ladder is the actual work. (Anchors re-derived at `17374679` now that #326 has merged; the pre-#326 figures were `:1080`, `:2368` and 1,288 lines.) | | 9 | **#1015** | OIDC relying party keys federated accounts on a reassignable username claim while the non-reassignable `sub` is discarded (ASVS 10.5.2) | 7 | 4 | _quick win_ | P1 | The RP keys federated identity on `oidc_username_claim` (default `preferred_username`), which an IdP may reassign, while the non-reassignable `sub` is verified and then dropped into an audit field — so on first deployment a new holder of a retired username would be handed the prior holder's account. Value matches **#1013** (7/4): both are auth-gate defects that admit an unauthenticated or wrong principal, and this one is more conditional (needs an IdP reassignment) but lands on an existing account rather than an empty one. Difficulty 4 and no migration cost, because there are no deployments to migrate (section 0) — key on `sub`, keep the username as a display attribute. | | 10 | **#318** | DAST — authenticated dynamic security testing of the running engine | 7 | 6 | _big bet_ | P2 | Increment 1 genuinely closed the §6.1 Dynamic row on the HTTP plane (`scripts/security/dast_auth_sweep.py`, `scripts/security/route_gates.py`, `.github/workflows/dast.yml` all present), but the unauthenticated MLLP/raw-TCP/X12 ingress — the one attacker-reachable surface — has no dynamic coverage and no mutator to extend, and a red nightly still notifies nobody (`.github/workflows/nightly-notice.yml:24` watches only `workflows: ["CI"]`); the remainder is a protocol fuzzer, an OpenAPI security overlay behind a fifth DEP-1 lock, a TLS black-box target and the `/ui` plane — cross-cutting and CI-gated. | | 11 | **#325** | Leak gate's home-path detector is case-blind on Windows paths | 6 | 2 | _quick win_ | P1 | A structural detector in a required merge context — the one control meant to work in a fork with no token source — fires on one of four spellings of the same Windows home path (`_HOME_PATH` compiles with no flags and matches a literal `Users`, `scripts/security/scan_forbidden.py:99-106`), against the module's own "fail toward more detection" rule, though the disclosure is an OS account name and the tree holds zero live hits; an inline `(?i:)` on the drive-letter arm only (whole-pattern `re.I` measured 47 false positives), the sibling two-character `_WORKTREE_SLUG:92` edit, and casing fixtures beside the sole canonical-case test at `tests/test_scan_tokens_source.py:559-577`. | @@ -207,78 +207,81 @@ Ordered by value descending, then difficulty ascending (cheapest first at equal | 28 | **#340** | Enable a GitHub merge queue: strict + no queue makes every merge a race that fails silently | 6 | 4 | _quick win_ | P2 | Build state confirmed: zero of the 21 files under `.github/workflows/` carries a `merge_group:` trigger, so difficulty 4 and the step-2-is-a-precondition reasoning are right. Value 8 is not. The rubric's `8` is "an ASVS L3 Partial on defaults, or a production blind spot with no workaround" — this is neither. It is a repo-workflow blind spot, and a workaround demonstrably exists and is exercised: `gh pr update-branch` (#74 landed via three merges from main, #119 landed via re-sync), plus a detector the project already BUILT for exactly this condition and which the item itself cites — `scripts/ci/check_stalled_prs.py` + `.github/workflows/stalled-prs.yml`. So the readiness signal is not in fact unfalsifiable from outside: a scheduled job reports the stalled set. That makes it "real gap, awkward workaround" = 6, one rung above the rubric's `4` for DX (the item's own cluster is Developer Experience & CI), and 6 is generous for a cluster the ladder caps at 4. At value 6, difficulty 4: quadrant stays quick win, but tier is P2 (P1 needs value >= 8, or value >= 6 at difficulty <= 2 — and this one is 4). | | 29 | **#1008** | Startup preflight on the store principal's effective privileges (ASVS 13.2.2) | 6 | 4 | _quick win_ | DEMAND-GATE | The engine documents a least-privilege store grant it can never observe — no fixed-server-role or database-role probe exists anywhere, and require_managed_identity gates credential kind not privilege, so a sysadmin gMSA passes — and the deferral that blocked this cleared on 2026-08-04 when the runbook fix landed; a serve-time refuse/warn probe on an existing seam, across three backends, coupled to a vault scorecard change it must not silently break. | | 30 | **#1002** | AG-rig validation: prove the multi-subnet failover reconnect | 6 | 4 | _quick win_ | P2 | Hardware-gated test execution, not a decision: `[store].multi_subnet_failover` shipped 2026-07-10 and is unit-tested only, so [`AOAG-DEPLOYMENT.md`](AOAG-DEPLOYMENT.md) §4.5 must keep mandating a planned DB outage until a real two-subnet AG proves the reconnect. Value anchored on **#1003** (7/4), one below because #1003 discharges four hardware-gated residuals where this discharges one documentation mandate; difficulty matches — same rig, a failover and a failback. Distinct from #1003, which covers #99/#98/#320/#351, none of them the cross-subnet reconnect. | -| 31 | **#180** | Cross-backend store migration tool | 6 | 5 | _quick win_ | DEMAND-GATE | Real gap — `open_store` picks a backend but nothing moves rows between them (no such subcommand exists in messagefoundry/__main__.py), so the only path discards retained history and audit; an offline row copy that re-wraps every `mfenc` body and reproduces the staged plus history shapes on all three backends. | -| 32 | **#332** | Release signing toolchain is unhashed | 6 | 5 | _quick win_ | P2 | Arbitrary code from any of ~30 floating transitives at `.github/workflows/release.yml:255` runs with the OIDC identity that then signs the wheel, writes the SLSA attestation and publishes to PyPI — a backdoored artifact carrying a *valid* Sigstore bundle and valid provenance — and no Dependabot ecosystem parses an inline `pip install X==Y`, so the pin rots with no trigger and no owner (the two siblings at `:104` and `:207`, the latter a `~=` range, float identically); the ADR 0034 hashed-lock mechanism is proven and running for `ci-scanners`/`ci-quality`, but `sigstore` is absent from every lock (`grep -c sigstore uv.lock` → 0), adding a seventh is a six-place lockstep edit, the resolve contamination may force the same excluded-by-decision call semgrep got, and no PR leg ever executes this path. | -| 33 | **#94** | External BLOB-server offload for embedded documents — stored-object pointer (OBX-5 RP) | 6 | 6 | _big bet_ | DEMAND-GATE | The strongest store-bloat lever for document-heavy feeds with only awkward workarounds (more disk, purge history), and ADR 0105 already reserved the pointer format and deref seam it plugs into (`messagefoundry/parsing/binary.py:55-62` `DOC_REF_MARKER`, shared-seam note at `:252`, content-address contract at `:264-266`); the remainder is still a pluggable BLOB connector family, a per-connection offload setting across three backends, and an ADR fixing where a write side-effect sits against the at-least-once invariant. | -| 34 | **#96** | Built-in "setup tester" — self-service capacity estimator | 6 | 6 | _big bet_ | DEMAND-GATE | An adopter-run pre-cutover capacity number has no substitute but the manual dev-harness-plus-TUNING-BASELINE exercise, so a real gap with an awkward workaround. The reuse premise is measured false — `knee` appears in `harness/` only in TOML profile comments and `__main__.py` has no `capacity`/`setup-test` subcommand — 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 + metrics: rubric band 6. It is not a 7: there is no 3-backend migration, and ADR 0074 already exists and needs amending, not writing. Quadrant stays big bet. | -| 35 | **#141** | TCP connection role selectable independently of direction (act-as-server vs act-as-client) | 6 | 6 | _big bet_ | DEMAND-GATE | Real firewall role-inversion gap that an external relay (socat/stunnel) works around awkwardly but genuinely, which is why it stays at moderate severity and P2; the outbound half is not a knob — `DestinationConnector` (`transports/base.py:459`) exposes only `send` (`:480`) and every destination dials (`tcp.py:189`, `mllp.py:849`, `x12.py:158`), so a listening outbound needs an accept loop handing a peer socket to the per-outbound delivery worker and reconciled with retry/backoff and the connection-lifecycle status vocabulary. | -| 36 | **#3** | Per-key (partition-key) message ordering (long-term, nice-to-have) | 6 | 9 | _big bet_ | DEMAND-GATE | The only order-preserving way to push one ordered feed past the ~60 msg/s one-lane-one-core bound; the engine-shard "workaround" is void (shards partition by connection) and the in-engine router-fanout substitute leaves transform serialized, so a real gap with only an awkward workaround. Nothing keyed exists (`partition_key`/`sequence_key`: zero hits in `messagefoundry/`), and keyed lane assignment with single-writer-per-lane over the durable outbox plus the A40 cross-key hazard is multi-week work sitting directly on the strict-FIFO invariant. Quadrant becomes big bet. | -| 37 | **#334** | semgrep, a required blocking gate, scans a two-directory allow-list | 5 | 2 | _fill-in_ | P2 | `security.yml:413` is still `semgrep --config .semgrep --error --metrics off messagefoundry tee` while bandit next door scans `-r .` at `:359`, and `tests/test_lint_scope_parity.py` — the control cited as stopping exactly this drift — mentions semgrep nowhere, so the project-specific rules directory silently skips the separately-versioned console wheel; bandit and CodeQL cover the sinks today, so this is breadth parity with a live compensating control, and the fix is one argument list mirroring bandit's `--exclude` plus one parity arm modelled on `:119-125`. | -| 38 | **#1009** | SOAP `body_secret_value_` is redacted, registered and documented — and never fingerprinted | 5 | 2 | _fill-in_ | P2 | `connector_secret_env_values`, the ASVS 13.3.4 runtime rotation fingerprinter, filters on bare `_SECRET_SETTING_KEYS` membership at `config/wiring.py:725` while `body_secret_value_` (emitted at `:2305`) reaches secrecy only through the prefix branch of `_is_secret_setting` at `:686`, so the class is masked on `/metadata`, registered in `CRITICAL_SECRETS` and given a documented rotation cadence yet never MAC'd — and the registration gate whose own comment promises the two sets "can never disagree" walks past it because it enumerates from the set the class never joined; one predicate swap onto the helper 53 lines above, plus the reverse assertion that gate is missing and a `Soap(body_secrets=...)` regression test. | -| 39 | **#1012** | ASVS gate summary line silently drops a verdict state: components sum to 344 against its own stated 345 | 5 | 2 | _fill-in_ | P3 | The gate prints five verdict states whose components sum to 344 while the same line states a 345 total — it omits `needs-review`, so the line cannot be reconciled against itself. Low value because no verdict is mis-scored and the scorecard remains the record of record; the cost is that the summary was quoted as "the distribution" across a full session and the omission propagated every time. Difficulty 2: emit the missing state and assert the components equal the stated total, which is the check whose absence allowed a count not to reconcile. | -| 40 | **#1016** | claims.py 500s on two malformed-IdP shapes with no closed-set audit row | 5 | 2 | _fill-in_ | P2 | Two narrow attacker-influenceable paths raise past the `ClaimsError` contract, so the response is a 500 with no closed-set audit row instead of a named claim rejection: `hmac.compare_digest` raises `TypeError` on a NON-ASCII str nonce (ASCII str-vs-str is legal, and the `isinstance` guard plus `or` short-circuit means non-ASCII is the ONLY remaining path — the fix belongs at the encoding boundary, not in a type check that already exists), and `set(aud)` raises on a list containing UNHASHABLE elements such as `[{"a": 1}]` (every non-list shape already falls through cleanly). Value 5: availability and audit completeness, not an auth bypass. Difficulty 2. | -| 41 | **#81** | Alert escalation tiers + day/time thresholds + content (Action-Point) alerting | 5 | 3 | _fill-in_ | DEMAND-GATE | Content-triggered ("Action Point") alerting is genuine Corepoint parity that nothing outside the tests can fire, but the escalation and schedule two-thirds already ship, leaving metadata-only breadth rather than a blocker; the remainder is hoisting `content_match` (`messagefoundry/pipeline/alert_sinks.py:726`) onto the `AlertSink` Protocol (`messagefoundry/pipeline/alerts.py:27`), exporting an emitter a Handler can reach without breaking re-run purity, and surfacing the already-durable `escalation_tier` (`messagefoundry/store/postgres.py:449`) on `AlertInstanceInfo`, which omits it (`messagefoundry/api/models.py:255-275`). | -| 42 | **#99** | AD/gMSA production-deployment hardening — turnkey enterprise (Windows/AD) install | 5 | 3 | _fill-in_ | DEMAND-GATE | Every code half is built — gMSA preflight + logon-right grant (`scripts/service/install-service.ps1:42-46`, `:286-303`), the MFA-claim hook on by default (`config/settings.py:1914`, enforced `:2184`), IIS/ARR and gMSA docs — leaving only (e), a live domain-lab smoke, whose fallback (ship with the caveat, validate at the first deployment) is workable: parity assurance with a clean workaround, value 5. Difficulty is 3, not 6: the residual lands almost no code through ruff/mypy/pytest; its cost is DC + AD CS + gMSA + proxy + joined-client provisioning the project does not own, which this rubric does not price as engineering — and the item's own 2026-07-28 amendment explicitly retires the 6/6 engineering framing. Quadrant becomes fill-in; still DEMAND-GATE behind #275. | -| 43 | **#125** | Uploaded Logs page - import external message files and browse them offline | 5 | 3 | _fill-in_ | DEMAND-GATE | The build-state finding is right (the five routes exist at api/app.py:3685/:3786/:3803/:3889/:3946 and `browse_uploaded_file`'s own docstring says "Returns metadata only — never a decrypted body"), but value 6 rests on the claim that the item's trigger — "inspect a partner-supplied message file without ingesting it" — is "still unserved". It is substantially served: the shipped browse route filters and searches by `content`, `field_path`/`field_value`, `message_type` and `control_id` over the decrypted split, and per-message resend exists, all without live ingest. What is missing is only the body DISPLAY, and for that the workaround is clean, not awkward: the operator personally uploaded the file, so it is already in their hands and readable in any text editor, and `dryrun --show-phi` prints bodies as well. That is rubric 5 — "parity/breadth with a clean workaround" — not 6's "awkward workaround". Difficulty 3 stands (a read-one/download route over the existing encrypted store plus the audited PHI-view treatment and an ADR 0134 amendment). Quadrant becomes fill-in, not quick win; tier is unchanged. | -| 44 | **#132** | Fixed 'now' test-time override (frozen clock for reproducible transform tests) | 5 | 3 | _fill-in_ | DEMAND-GATE | Value 5 stands (a wall-clock-free transform or a tolerant diff gets regression comparison today — "parity/breadth with a clean workaround"), and the seam claim is verified: `route_message` takes `ingest_time` at dryrun.py:517 and the two internal call sites hardwire `time.time()` at :679 (`_dry_run_raw`) and :753 (`dry_run`). But "a --now flag threaded through two entry points" undercounts the surfaces, and the ones it misses are the ones the item is ABOUT. `checks.py:1058,1126` calls `dry_run(reg, raw, inbound=..., snapshot_on_send=...)` with no ingest_time — and checks.py is the `.expect` fixture comparator, i.e. the repo's actual deterministic-regression gate. `trace_dry_run` is a separate module (`dryrun_trace`, invoked from __main__.py:2926-2931). And the item's own Trigger names the Test Bench: ide/src/testBench.ts shells `dryrun` at five sites (:240, :325, :354, :440) and would need the flag plus an affordance. Engine + CLI + fixture gate + a TypeScript extension is D3 work, not D2's "small additive change on an existing seam". Quadrant stays fill-in; tier stays DEMAND-GATE. | -| 45 | **#172** | Gzip/zip compression codec + file-connector option | 5 | 3 | _fill-in_ | DEMAND-GATE | File-feed parity breadth with a clean code-first workaround: the reusable codec shipped including `zip_compress`/`zip_decompress` (`messagefoundry/parsing/compression.py:40-48`), so a zip-delivering partner is served by a Handler call today. What remains is connector-level — widening `_SUPPORTED_COMPRESSION` (`messagefoundry/transports/file.py:88`), which forces an archive-member-to-message decision, plus REMOTEFILE, which has zero compression to extend. | -| 46 | **#336** | Dependabot auto-merge shields review with a deny-list | 5 | 3 | _fill-in_ | P2 | Auto-merge still keys only on `update-type == 'version-update:semver-patch'` behind a 16-name Python deny-list with no ecosystem filter, so npm and `github-actions` — artifacts that execute inside CI holding the job's token — have zero shield coverage, and `tests/test_dependabot_automerge_guardrails.py:107-108` still asserts a cooldown for the `uv` ecosystem alone; the remainder is a deny-to-allow inversion in one workflow, a workflow-side release-age check for the cooldown-bypassing security track, and broadening one test. | +| 31 | **#1021** | The MFA enrollment confirm verifies the activating TOTP through a bool wrapper that discards the step, so it is never consumed (ASVS 6.5.1) | 6 | 4 | _quick win_ | P2 | `confirm_mfa_enrollment` verifies the enrolling code with `totp.verify_totp` (`auth/service.py:1979`), a documented thin bool wrapper that computes the matched step then collapses it to a bool (`auth/totp.py:150`), so the step is never passed to `consume_totp_step`. It is reachable rather than incidentally blocked because `enable_totp` leaves `users.last_totp_step` NULL in all three backends (`store/store.py:7752-7764`, `sqlserver.py:9095`, `postgres.py:6165`) and the compare-and-set accepts any step against a NULL mark (`store/store.py:7824`) — so on first deployment the activating code would still be accepted by `POST /auth/mfa-verify` on a separate password-authenticated session for the remainder of its own 30-second step (`totp_skew_steps` defaults to 0, `config/settings.py:1736`). Value 6 not higher: exploitation needs the password plus a same-step code capture, and the enrollment route already sits behind an action-bound step-up. Difficulty 4 is test collateral rather than code — the production change is three lines reusing the primitive that already exists on the login path, but at least four tests confirm an enrollment then assert a live verify inside the same step, and `fresh_totp` guarantees headroom within a step without advancing one, so each needs restructuring. | +| 32 | **#180** | Cross-backend store migration tool | 6 | 5 | _quick win_ | DEMAND-GATE | Real gap — `open_store` picks a backend but nothing moves rows between them (no such subcommand exists in messagefoundry/__main__.py), so the only path discards retained history and audit; an offline row copy that re-wraps every `mfenc` body and reproduces the staged plus history shapes on all three backends. | +| 33 | **#332** | Release signing toolchain is unhashed | 6 | 5 | _quick win_ | P2 | Arbitrary code from any of ~30 floating transitives at `.github/workflows/release.yml:255` runs with the OIDC identity that then signs the wheel, writes the SLSA attestation and publishes to PyPI — a backdoored artifact carrying a *valid* Sigstore bundle and valid provenance — and no Dependabot ecosystem parses an inline `pip install X==Y`, so the pin rots with no trigger and no owner (the two siblings at `:104` and `:207`, the latter a `~=` range, float identically); the ADR 0034 hashed-lock mechanism is proven and running for `ci-scanners`/`ci-quality`, but `sigstore` is absent from every lock (`grep -c sigstore uv.lock` → 0), adding a seventh is a six-place lockstep edit, the resolve contamination may force the same excluded-by-decision call semgrep got, and no PR leg ever executes this path. | +| 34 | **#1017** | worktree_gate rule 3d has no ownership signal, so it denies a session removing a worktree it created itself | 6 | 5 | _quick win_ | P3 | Rule 3d denies on three conditions only — a git token, a `worktree remove|move` match, and a target resolving under a governed root (`scripts/hooks/worktree_gate.ps1:509-531`) — and consults nothing about ownership: grepping the block for `cwd` returns zero hits and the gate never reads `session_id`. Ownership is inferred from an invalid premise in the rule's own header (`:500`), that a remove reaching git is aimed at somebody else's tree by construction, and the deny then asserts as fact that it belongs to another session (`:535`). Measured rather than inferred: the gate's receipt log records 4 rule=3d denies and 4 of 4 came from a session standing in its own nested checkout. It bites because the remediation it offers cannot act on that class — `prune-merged.ps1:123-124` excludes the `.claude/worktrees` and scratchpad layouts by contract — and G11 already records that nested worktrees have no scripted removal, so the denied command is the only route. Value 6 for a measured false positive on a control whose efficacy rests on its deny text being believed (G10); held below 7 as Claude-process tooling with working fallbacks. Difficulty 5 because the payload carries no session identity, so a fix means recording creation provenance at `add` time — a new mechanism, not a new condition. | +| 35 | **#94** | External BLOB-server offload for embedded documents — stored-object pointer (OBX-5 RP) | 6 | 6 | _big bet_ | DEMAND-GATE | The strongest store-bloat lever for document-heavy feeds with only awkward workarounds (more disk, purge history), and ADR 0105 already reserved the pointer format and deref seam it plugs into (`messagefoundry/parsing/binary.py:55-62` `DOC_REF_MARKER`, shared-seam note at `:252`, content-address contract at `:264-266`); the remainder is still a pluggable BLOB connector family, a per-connection offload setting across three backends, and an ADR fixing where a write side-effect sits against the at-least-once invariant. | +| 36 | **#96** | Built-in "setup tester" — self-service capacity estimator | 6 | 6 | _big bet_ | DEMAND-GATE | An adopter-run pre-cutover capacity number has no substitute but the manual dev-harness-plus-TUNING-BASELINE exercise, so a real gap with an awkward workaround. The reuse premise is measured false — `knee` appears in `harness/` only in TOML profile comments and `__main__.py` has no `capacity`/`setup-test` subcommand — 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 + metrics: rubric band 6. It is not a 7: there is no 3-backend migration, and ADR 0074 already exists and needs amending, not writing. Quadrant stays big bet. | +| 37 | **#141** | TCP connection role selectable independently of direction (act-as-server vs act-as-client) | 6 | 6 | _big bet_ | DEMAND-GATE | Real firewall role-inversion gap that an external relay (socat/stunnel) works around awkwardly but genuinely, which is why it stays at moderate severity and P2; the outbound half is not a knob — `DestinationConnector` (`transports/base.py:459`) exposes only `send` (`:480`) and every destination dials (`tcp.py:189`, `mllp.py:849`, `x12.py:158`), so a listening outbound needs an accept loop handing a peer socket to the per-outbound delivery worker and reconciled with retry/backoff and the connection-lifecycle status vocabulary. | +| 38 | **#3** | Per-key (partition-key) message ordering (long-term, nice-to-have) | 6 | 9 | _big bet_ | DEMAND-GATE | The only order-preserving way to push one ordered feed past the ~60 msg/s one-lane-one-core bound; the engine-shard "workaround" is void (shards partition by connection) and the in-engine router-fanout substitute leaves transform serialized, so a real gap with only an awkward workaround. Nothing keyed exists (`partition_key`/`sequence_key`: zero hits in `messagefoundry/`), and keyed lane assignment with single-writer-per-lane over the durable outbox plus the A40 cross-key hazard is multi-week work sitting directly on the strict-FIFO invariant. Quadrant becomes big bet. | +| 39 | **#1009** | SOAP `body_secret_value_` is redacted, registered and documented — and never fingerprinted | 5 | 2 | _fill-in_ | P2 | `connector_secret_env_values`, the ASVS 13.3.4 runtime rotation fingerprinter, filters on bare `_SECRET_SETTING_KEYS` membership at `config/wiring.py:725` while `body_secret_value_` (emitted at `:2305`) reaches secrecy only through the prefix branch of `_is_secret_setting` at `:686`, so the class is masked on `/metadata`, registered in `CRITICAL_SECRETS` and given a documented rotation cadence yet never MAC'd — and the registration gate whose own comment promises the two sets "can never disagree" walks past it because it enumerates from the set the class never joined; one predicate swap onto the helper 53 lines above, plus the reverse assertion that gate is missing and a `Soap(body_secrets=...)` regression test. | +| 40 | **#1012** | ASVS gate summary line silently drops a verdict state: components sum to 344 against its own stated 345 | 5 | 2 | _fill-in_ | P3 | The gate prints five verdict states whose components sum to 344 while the same line states a 345 total — it omits `needs-review`, so the line cannot be reconciled against itself. Low value because no verdict is mis-scored and the scorecard remains the record of record; the cost is that the summary was quoted as "the distribution" across a full session and the omission propagated every time. Difficulty 2: emit the missing state and assert the components equal the stated total, which is the check whose absence allowed a count not to reconcile. | +| 41 | **#1016** | claims.py 500s on two malformed-IdP shapes with no closed-set audit row | 5 | 2 | _fill-in_ | P2 | Two narrow attacker-influenceable paths raise past the `ClaimsError` contract, so the response is a 500 with no closed-set audit row instead of a named claim rejection: `hmac.compare_digest` raises `TypeError` on a NON-ASCII str nonce (ASCII str-vs-str is legal, and the `isinstance` guard plus `or` short-circuit means non-ASCII is the ONLY remaining path — the fix belongs at the encoding boundary, not in a type check that already exists), and `set(aud)` raises on a list containing UNHASHABLE elements such as `[{"a": 1}]` (every non-list shape already falls through cleanly). Value 5: availability and audit completeness, not an auth bypass. Difficulty 2. | +| 42 | **#81** | Alert escalation tiers + day/time thresholds + content (Action-Point) alerting | 5 | 3 | _fill-in_ | DEMAND-GATE | Content-triggered ("Action Point") alerting is genuine Corepoint parity that nothing outside the tests can fire, but the escalation and schedule two-thirds already ship, leaving metadata-only breadth rather than a blocker; the remainder is hoisting `content_match` (`messagefoundry/pipeline/alert_sinks.py:726`) onto the `AlertSink` Protocol (`messagefoundry/pipeline/alerts.py:27`), exporting an emitter a Handler can reach without breaking re-run purity, and surfacing the already-durable `escalation_tier` (`messagefoundry/store/postgres.py:449`) on `AlertInstanceInfo`, which omits it (`messagefoundry/api/models.py:255-275`). | +| 43 | **#99** | AD/gMSA production-deployment hardening — turnkey enterprise (Windows/AD) install | 5 | 3 | _fill-in_ | DEMAND-GATE | Every code half is built — gMSA preflight + logon-right grant (`scripts/service/install-service.ps1:42-46`, `:286-303`), the MFA-claim hook on by default (`config/settings.py:1914`, enforced `:2184`), IIS/ARR and gMSA docs — leaving only (e), a live domain-lab smoke, whose fallback (ship with the caveat, validate at the first deployment) is workable: parity assurance with a clean workaround, value 5. Difficulty is 3, not 6: the residual lands almost no code through ruff/mypy/pytest; its cost is DC + AD CS + gMSA + proxy + joined-client provisioning the project does not own, which this rubric does not price as engineering — and the item's own 2026-07-28 amendment explicitly retires the 6/6 engineering framing. Quadrant becomes fill-in; still DEMAND-GATE behind #275. | +| 44 | **#125** | Uploaded Logs page - import external message files and browse them offline | 5 | 3 | _fill-in_ | DEMAND-GATE | The build-state finding is right (the five routes exist at api/app.py:3685/:3786/:3803/:3889/:3946 and `browse_uploaded_file`'s own docstring says "Returns metadata only — never a decrypted body"), but value 6 rests on the claim that the item's trigger — "inspect a partner-supplied message file without ingesting it" — is "still unserved". It is substantially served: the shipped browse route filters and searches by `content`, `field_path`/`field_value`, `message_type` and `control_id` over the decrypted split, and per-message resend exists, all without live ingest. What is missing is only the body DISPLAY, and for that the workaround is clean, not awkward: the operator personally uploaded the file, so it is already in their hands and readable in any text editor, and `dryrun --show-phi` prints bodies as well. That is rubric 5 — "parity/breadth with a clean workaround" — not 6's "awkward workaround". Difficulty 3 stands (a read-one/download route over the existing encrypted store plus the audited PHI-view treatment and an ADR 0134 amendment). Quadrant becomes fill-in, not quick win; tier is unchanged. | +| 45 | **#132** | Fixed 'now' test-time override (frozen clock for reproducible transform tests) | 5 | 3 | _fill-in_ | DEMAND-GATE | Value 5 stands (a wall-clock-free transform or a tolerant diff gets regression comparison today — "parity/breadth with a clean workaround"), and the seam claim is verified: `route_message` takes `ingest_time` at dryrun.py:517 and the two internal call sites hardwire `time.time()` at :679 (`_dry_run_raw`) and :753 (`dry_run`). But "a --now flag threaded through two entry points" undercounts the surfaces, and the ones it misses are the ones the item is ABOUT. `checks.py:1058,1126` calls `dry_run(reg, raw, inbound=..., snapshot_on_send=...)` with no ingest_time — and checks.py is the `.expect` fixture comparator, i.e. the repo's actual deterministic-regression gate. `trace_dry_run` is a separate module (`dryrun_trace`, invoked from __main__.py:2926-2931). And the item's own Trigger names the Test Bench: ide/src/testBench.ts shells `dryrun` at five sites (:240, :325, :354, :440) and would need the flag plus an affordance. Engine + CLI + fixture gate + a TypeScript extension is D3 work, not D2's "small additive change on an existing seam". Quadrant stays fill-in; tier stays DEMAND-GATE. | +| 46 | **#172** | Gzip/zip compression codec + file-connector option | 5 | 3 | _fill-in_ | DEMAND-GATE | File-feed parity breadth with a clean code-first workaround: the reusable codec shipped including `zip_compress`/`zip_decompress` (`messagefoundry/parsing/compression.py:40-48`), so a zip-delivering partner is served by a Handler call today. What remains is connector-level — widening `_SUPPORTED_COMPRESSION` (`messagefoundry/transports/file.py:88`), which forces an archive-member-to-message decision, plus REMOTEFILE, which has zero compression to extend. | | 47 | **#1014** | connscale smoke test's fixed 24-port block is not parallel-safe across worktrees; the flaky marker hides the collision | 5 | 3 | _fill-in_ | P3 | `test_connscale_smoke_end_to_end` hard-codes `base_port = 41000` and needs 24 CONTIGUOUS inbound ports, so two checkouts running the suite at once contend for the same block — which is the normal topology here (24 worktrees were live on 2026-08-04). It self-heals via `@pytest.mark.flaky(reruns=2)` commented "CI runners are noisy", so a determinate resource collision wears a noise label and the retry does work the port allocation should be doing. Value 5: it costs retries and misdiagnosis rather than correctness. Difficulty 3: allocate the block dynamically and assert contiguity, rather than widening the retry. | -| 48 | **#236** | Test-this-step and test-up-to-step with pinned upstream values | 5 | 4 | _fill-in_ | P2 | Real debug breadth — whole-handler traced values already fold onto rows (`mergeLiveValues`, ide/src/stepsModel.ts:544) so partial runs are a convenience, but pinning an expensive `db_lookup`/`fhir_lookup` has no equivalent at all; largely a stop condition plus state dump on ADR 0072's shipped trace, with the lookup mock and keeping `buildLensTraceArgs` (:674) incapable of emitting `--show-phi` the real work. | -| 49 | **#165** | DB schema browser + ad-hoc query runner | 5 | 5 | _fill-in_ | DEMAND-GATE | Corepoint-parity authoring aid whose external-SQL-client workaround is fully clean — the only DB reach today is the `SELECT 1` reachability probe (`messagefoundry/transports/database.py:484-501`) and dry-run refuses `db_lookup` (`messagefoundry/pipeline/dryrun.py:570`); the build is a net-new API surface plus per-dialect introspection, read-only statement gating, a permission, audit and a console pane. | -| 50 | **#232** | Steps view for routers | 5 | 5 | _fill-in_ | P2 | Real Steps-view breadth gap exactly where destination selection is decided, with a workaround — read a five-line guard-and-return — clean enough to hold it off the top; a `route` row kind widens the ADR 0076 §3 grammar, so an amendment lands first, then `return []` disambiguation in a lens that skips routers outright today (messagefoundry/lens.py:306, :344-347), a router palette, and byte-stable rewrite parity. | -| 51 | **#78** | Custom message-definition data model + conformance validator; NCPDP codec | 5 | 6 | _money pit_ | DEMAND-GATE | Corepoint-parity persisted-definition model plus a report-only validator and an additive NCPDP codec, all cleanly worked around today by a code-first Handler, so useful breadth rather than a blocker; the whole scope is still remainder — NCPDP appears nowhere in `messagefoundry/` and `profile` is merely "reserved for a conformance-profile" (`messagefoundry/parsing/validate.py:56`) — spanning a new stored model the code reads, a validator, and a new codec class. | -| 52 | **#85** | Cloud object-store + generic message-bus destinations | 5 | 6 | _money pit_ | DEMAND-GATE | Corepoint-parity transport breadth with a clean workaround — the pluggable destination registry lets an adopter write the connector code-first — and nothing exists today (`transports/` carries no object-store or bus driver; `pyproject.toml` names no boto3/azure/google-cloud/kafka dependency). But the scored remainder is the whole scope: four-plus drivers, four vetted dependencies through the hash-locked lock file, plus credential sourcing and egress allow-listing on each, which exceeds the single-connector band 5. Quadrant becomes money pit. | -| 53 | **#127** | Web-proxy credential types (Basic / Digest / NTLM / Windows) | 5 | 6 | _money pit_ | DEMAND-GATE | Breadth with a clean, ADR-ratified workaround — `cntlm` in front of the engine covers the enterprise NTLM proxy, and Basic already tunnels through `CONNECT`; the remainder is not a knob but a keep-alive HTTP client under `transports/rest.py`, because `urllib.request` opens a new connection per `open()` and the NTLM type1/2/3 handshake is connection-bound — the refusal is asserted at `messagefoundry/transports/rest.py:993-997` for the same reason #65 scoped it out (`transports/http_auth.py:27-31`), across four connector factories plus an ADR 0126 amendment. | -| 54 | **#342** | Sandbox worker kill does not reap a grandchild holding the response pipe | 5 | 6 | _money pit_ | P2 | Build state confirmed open: `pipeline/sandbox.py:327` is a bare `proc.kill()` and the module contains no `creationflags` and no `start_new_session`. Value 5 holds — #339's per-dispatch `secrets.token_hex(16)` really does bound this to availability and orphan accumulation on an opt-in posture. Difficulty 5 is the error, and the scorer's own why states the disqualifying fact: the fix "wants verifying on the Windows CI leg". The rubric prices `6` as "cross-cutting ... or Windows-CI-gated", and `5` as "a new connector/codec behind the transport registry" — which this is not. On top of the CI gate, the Windows half has no stdlib API (a kill-on-close job object means ctypes against `CreateJobObject`/`SetInformationJobObject` or a vetted new dependency), and the POSIX half is a different mechanism (`start_new_session` + `killpg`), so it is two platform implementations plus a platform-gated test. At value 5 / difficulty 6 the quadrant is money pit, not fill-in; tier stays P2 (value >= 5). | -| 55 | **#62** | Binary body carriage — store ciphertext / raw bodies as `VARBINARY`/`BLOB`/`bytea` instead of base64-in-`NVARCHAR` | 5 | 7 | _money pit_ | DEMAND-GATE | Corepoint-class ~60% at-rest win on SQL Server where the only workaround is a bigger disk, but it is measure-gated and never load-bearing on correctness; a carriage format change that re-opens ADR 0028's NUL-safe str/TEXT decision, needs its own ADR, and drags a dual-read migration over three backends and two live `mfenc:` versions. | -| 56 | **#130** | Message queues shared by name across connections + shared-name delete protection | 5 | 8 | _money pit_ | DEMAND-GATE | Parity breadth with a clean workaround — the name-wired graph already fans a router across handlers and a handler across outbounds, and nothing (zero `shared_queue`/`queue_name` hits in `messagefoundry/`) suggests a named queue is needed to express a real feed; building it adds a store seam keyed by name rather than connection, competing consumers claiming under per-lane FIFO, and reference-counted delete, on all three backends without letting the abstraction become the "channel" element CLAUDE.md forbids. | -| 57 | **#137** | Configurable server display name in the operator console | 4 | 2 | _fill-in_ | DEMAND-GATE | Value 4 is right (console polish; the URL/port already disambiguate, and monitoring.py:508 already renders a "Node id" row, so nobody is blocked), and the stale-module finding is right — there is no messagefoundry/console/, and the live title is `el("title", f"{title} — MessageFoundry")` at _html.py:171. But D2→3 rests on a false premise: "the console never imports the engine, so the label has to ride an API status response rather than being read from settings in-process". The console does not import the engine, yet the engine INJECTS a typed bundle into it at mount time — `mount_ui(app: FastAPI, deps: UiDeps)` (messagefoundry_webconsole/mount.py:69), and `UiDeps` (messagefoundry/api/_ui_seam.py:199) already carries settings-derived display values of exactly this shape, e.g. `organization_domains` (:224) and `oidc_authorization_host` (:231-234), the latter documented as "Derived from settings, never from request input". A server display name is one more UiDeps field plus a read in `page()` — no HTTP boundary crossing, no status-response plumbing. That is D2, "small additive change on an existing seam". Quadrant stays fill-in; tier stays DEMAND-GATE. | -| 58 | **#167** | Test Bench metadata seeding | 4 | 2 | _fill-in_ | DEMAND-GATE | IDE Test Bench DX input to seed the per-message metadata bag for transform tests; nobody is blocked, and the seam is small — a `--meta` flag threaded through `dry_run`/`route_message` (`messagefoundry/pipeline/dryrun.py:512-521`, `:702-709`) into the Test Bench's CLI-only channel (`ide/src/testBench.ts:240`). The bag itself already shipped (#150/ADR 0081, `messagefoundry/config/wiring.py:2604`) but write-only — no `meta_get` on `Message` — which is a clause of this item's OWN trigger, so it holds the tier at DEMAND-GATE without discounting worth-if-built. | -| 59 | **#171** | Runtime log-verbosity control + in-product log viewer | 4 | 2 | _fill-in_ | DEMAND-GATE | Ops convenience whose live-incident use case the built API half already answers — `set_runtime_level`/`current_log_level` (`messagefoundry/logging_setup.py:429`, `:452`) behind `GET`/`PATCH /logging/level` and `GET /logs/tail` (`messagefoundry/api/app.py:4566`, `:4580`, `:4609`); the remainder is pure wiring, since the console JS is already written (`messagefoundry_webconsole/static/app.js:1252`, `:1294`) and only needs a page builder to emit its attributes plus the two absent `/ui` routes and a golden-surface update. | -| 60 | **#177** | Effective-permission inspector for a user | 4 | 2 | _fill-in_ | DEMAND-GATE | The endpoint shipped (`GET /users/{user_id}/permissions`, `messagefoundry/api/auth_routes.py:610`), so the manual `/users`×`/roles` cross-ref the 5 priced is already gone and the remainder is console polish over a built surface; an apiclient wrapper plus a card on the existing `/ui/users/{user_id}` page — whose builder renders only profile/roles/scope/actions (`messagefoundry_webconsole/pages/admin.py:152-158`) — and a golden-surface update. | -| 61 | **#228** | Steps / config search finds handlers, routers, and transforms by name (not just connections) | 4 | 2 | _fill-in_ | P3 | Authoring polish on an index that already ships — a hit opens source instead of the Steps view and send targets stay unindexed; both are small additive edits, (a) a `contextValue` on rows that already carry `elementKind`/`elementName`. | -| 62 | **#124** | Batch-export message bodies from a connection log to a file | 4 | 3 | _fill-in_ | DEMAND-GATE | Console polish now that the capability itself ships — a scripted operator exports today through the audited step-up route, leaving only the save-selected affordance; the JS is already written (`messagefoundry_webconsole/static/app.js:1380`), so the cost is emitting the `data-mf-*` attributes and row checkboxes in `pages/messages.py` and registering `/ui/messages/export` ahead of `/ui/messages/{message_id}` (`routes/core.py:468`) so the path parameter cannot swallow it. | -| 63 | **#133** | User-chosen display colour on configuration objects | 4 | 3 | _fill-in_ | DEMAND-GATE | Value 4 ("DX or console polish") is right and the stale-citation finding is right (no messagefoundry/console/ package; the live chrome is _html.py's page() head). But D3→2 rests on "a colour is that same shape [as `flagged`] plus a render", and that is false in a way this codebase enforces. `flagged` is a bool with no rendering sink; a colour is an operator-supplied STRING rendered into console markup, and the /ui CSP is `style-src 'self'` with no 'unsafe-inline' (_security.py:205, _auth.py:141, and app.css:2 states the constraint outright). An inline `style="…"` colour would simply not render, so the build must either bind a fixed palette to CSS classes shipped in app.css or add a nonce'd style mechanism the CSP does not currently grant for styles — a design decision plus value validation on untrusted config input, on top of the config-model → TOML → API → console thread. That is D3 ("a new setting into one connector"-scale work), not D2's "default flip or doc edit"-adjacent band. Quadrant stays fill-in; tier stays DEMAND-GATE. | -| 64 | **#234** | Steps view projection refreshes on save only | 4 | 3 | _fill-in_ | P3 | UX latency on an opt-in authoring surface, not a correctness gap — the rows merely lag the buffer while live values stay correctly save-gated (ide/src/stepsView.ts:327); the debounce already exists at :89, but relaxing a deliberate ADR 0076 §5 guardrail means an amendment plus proving `EditLoopGuard` holds when projection races an in-flight `lens rewrite`. | -| 65 | **#343** | Sandbox child stderr is inherited unframed into the engine log stream | 4 | 3 | _fill-in_ | P3 | The worker is still spawned `stderr=None` (`pipeline/sandbox.py:266`), so a sandboxed Handler's bytes land in the engine's own log stream unattributed and a `print()` of a body writes PHI at whatever level the operator runs — but the same `print()` under the default `mode=off` reaches the same stream, so the sandbox-specific loss is attribution and the fd-1 framing that survives on luck rather than design; a `stderr=subprocess.PIPE` relay thread through the stdlib logger (inheriting the existing PHI filters) plus a bootstrap redirect of the child's `sys.stdout`, all inside one module. | -| 66 | **#346** | The sandbox import boundary is enforced only at runtime, under an off-by-default flag | 4 | 3 | _fill-in_ | P3 | The scorer verified the item's own measurement (`FORBIDDEN_MODULES` appears nowhere under `tests/`, confirmed) and inherited its conclusion — but the conclusion is the part that is false. The item's load-bearing claim is that "a re-violation is invisible to a green suite" because the guard runs only in the child under a non-default flag. `tests/test_sandbox.py` runs REAL `mode=SUBPROCESS` sessions across roughly a dozen tests (`test_subprocess_parity_router_and_handler`, `test_subprocess_marshals_live_store_run_context`, `test_generator_router_routes_under_mode_subprocess`, `test_setstate_tuple_and_nonfinite_values_survive_mode_subprocess`, ...) — the child is genuinely spawned, since the OFF test asserts `off._proc is None` as the distinguishing property. Decisively, `test_response_view_reaches_a_sandboxed_handler` (~:617-645) drives a `CapturedResponse` through a live subprocess round-trip, i.e. the exact violation instance the item is built on would now be caught red by CI. So the compensating control is a live test file, not absent, and the residual narrows to a FUTURE codec type added without an accompanying subprocess-mode test. That is test-coverage hardening = value 4, not "real gap, awkward workaround" = 6. Difficulty 3 stands (an `ast` walker anchored on the constant, falsified against a planted import). At value 4 the tier is P3 (P2 needs value >= 5) and the quadrant is fill-in. | -| 67 | **#351** | SQL Server failover test asserts on a 0.35s wall-clock margin across a real DB round-trip | 4 | 3 | _fill-in_ | P3 | One observation on one leg, with the 2022 leg passing the same commit and a sibling PR passing both, bounds this to a marginal test whose red misattributes to whichever PR it fires on — the residual worth is settling whether #348's work at the `_acquire` chokepoint merely spent latency the test had no headroom for or tipped a real delay-predicate regression; the edit is confined to one test file, but it cannot be validated locally by default (the SQL Server leg silently skips) and must not be landed as a wider margin before the question is answered. | -| 68 | **#166** | Server-side per-user console preferences | 4 | 4 | _fill-in_ | DEMAND-GATE | Roaming console settings stay polish nobody is blocked on; the cost the 6 priced is gone — the Qt half is retired and #151 already shipped the owner-keyed per-user store + route template (`messagefoundry/store/store.py:1667-1681`), so the remainder is a second additive table across three backends plus web-console wiring, no pipeline. | -| 69 | **#235** | Generate Steps view parameter forms from Python type hints | 4 | 4 | _fill-in_ | P3 | Authoring polish — the recognized row set is unchanged and only the widgets get richer over the literal-only slots the lens marks today (messagefoundry/lens.py:255); a stdlib `inspect` schema emitter beside the 315-line `actions.py` plus replacing the hand-rolled per-op rendering in a 2,328-line model (`ADD_MENU_CATALOG`, ide/src/stepsModel.ts:886). | -| 70 | **#237** | Per-argument input modes (static templated dynamic) in the Steps view | 4 | 4 | _fill-in_ | P3 | Authoring polish that renames "not editable" honestly without unlocking a new edit class — dynamic mode stays read-only in v1 by its own sketch; the value classifier is net-new in `lens.py`, then a mode selector on the same form surface #235 rewrites, sequenced behind #233. | -| 71 | **#108** | Receiver-side 'Prefer BOM if present' encoding auto-detect | 3 | 2 | _fill-in_ | DEMAND-GATE | A configured per-connection `encoding` already covers any single-encoding feed cleanly — it is plumbed through to `normalize(raw, *, encoding=…)` on the hot path (`messagefoundry/parsing/peek.py:152-162`) and accepts `utf-8-sig`/`utf-16-le`/`utf-16-be` — leaving only the niche mixed-BOM override, a niche interop knob; the remainder is a small additive sniff on the decode path, since no UTF-16 byte-order mark is detected anywhere today. | -| 72 | **#148** | X12 TA1 interchange-acknowledgement generation | 3 | 2 | _fill-in_ | DEMAND-GATE | Niche X12 knob most partners never need — the pyx12 walk yields a conforming 997/999 free (`parsing/x12/validate.py:18`, `:69`), covering the common ack, and only a contract that specifically mandates interchange-level accept/reject reaches for TA1; the build is a pure codec addition beside the existing splitter and delimiters in `messagefoundry/parsing/x12/`, which today contains no TA1 generator at all — only the outbound classifies a partner's returned TA1 (`transports/x12.py:73-74`). | -| 73 | **#184** | Serve own endpoint WSDL | 3 | 2 | _fill-in_ | DEMAND-GATE | Niche SOAP interop knob with a clean out-of-band-WSDL workaround; a configured document served off the listener's existing GET/HEAD health short-circuit (messagefoundry/transports/http_listener.py:796-797), which already returns before any ingress row. | -| 74 | **#249** | `lens graph`: mermaid and dot export formats | 3 | 2 | _fill-in_ | P3 | `graph --json` already ships (`messagefoundry/__main__.py:156-159`), so a mermaid/dot emitter is convenience over an already-complete surface rather than a capability anyone lacks; two pure-string emitters over the existing graph model, no new dependency and no seam crossed. | -| 75 | **#338** | TLS key-exchange groups are inherited, not pinned | 3 | 2 | _fill-in_ | P3 | `harden_kex_groups` still returns `None` when `set_groups` is absent, and all three restatements survive the 2026-07-29 sweep — `CONTAINER-EXPOSURE-EVALUATION.md` still says "hardened KEX groups" under a *verification* heading, `BACKLOG.md:6422` still lists 11.6.2 in #200's Closes line against PHI.md's PARTIAL, and `ASVS-L2-PHASE0-CHANGES.md:254` still presupposes a pin — but every group that gets in is forward-secret and the floor plus `harden_cipher_suites` admit nothing static, so this is documentation accuracy plus observability; three doc edits and one additive report-only `SecurityPosture` field beside `fips_attestation()`, with the two tripwire tests left alone as the 3.15 trigger. | -| 76 | **#83** | Rich file-output disposition + FTPS / SFTP variants | 3 | 3 | _fill-in_ | DEMAND-GATE | Niche file/FTP interop knobs most partners never need, and the ones that bite are transport-side where no Handler can substitute; all of it is per-driver additive on two connectors — `FileDestination` still has no append, dated-subfolder archive or header/trailer framing knob, and `remotefile` is explicit-`FTP_TLS` only with no implicit/passive toggle or keyboard-interactive auth (`messagefoundry/transports/remotefile.py:13`, `:256-262`). | -| 77 | **#98** | Kerberos SSO channel-binding (EPA) opt-in + acceptor-enforcement spike | 3 | 3 | _fill-in_ | DEMAND-GATE | Narrow EPA hardening on an opt-in in-process-TLS SSO mode nobody is blocked on, and structurally void behind a TLS-terminating proxy, so a niche interop knob at best; the acceptors are still constructed with no bindings at all (`spnego.server(service=…)` / `spnego.server()` at `messagefoundry/auth/ldap.py:300-302`, `:360-362`, with no `channel_bindings` argument or CBT knob anywhere), so the work is a spike plus one conditional per-mode flag — but the answer needs the same domain lab #99(e) is blocked on. | -| 78 | **#159** | TCP stream-until-close (no-framing) mode | 3 | 3 | _fill-in_ | DEMAND-GATE | Niche close-framed TCP interop knob: `codec_for` requires both delimiter bytes and `FrameCodec` rejects `start == end` (`messagefoundry/transports/framing.py:62-63`, `:167-170`), so connection-close framing is inexpressible today; a `framing=none` path bypasses the shared codec on the Tcp read loop (`messagefoundry/transports/tcp.py:508-515`) and the destination's write-then-close. | -| 79 | **#163** | Static-string inbound ACK | 3 | 3 | _fill-in_ | DEMAND-GATE | Canned-ACK interop knob most partners never need — `AckMode` offers only original/enhanced/none (`messagefoundry/config/models.py:98-103`) and `build_ack` always assembles MSH+MSA (`messagefoundry/transports/mllp.py:329-350`); a new mode plus a literal setting through wiring into the one MLLP listener, with the synchronous NAK path decided. | -| 80 | **#178** | SFTP cipher / KEX / MAC allow-lists | 3 | 3 | _fill-in_ | DEMAND-GATE | Niche knob a FIPS-restricted partner needs — `client.connect` passes no `disabled_algorithms` (`messagefoundry/transports/remotefile.py:396-405`), so only host-key posture is operator-configurable. Cost is a new validated operator setting into one connector, and the Scope's second clause (preferred-ordering on the SSH Transport) is not reachable through `SSHClient.connect` — it must be set on the Transport before negotiation, so `_make_client` restructures rather than gaining one kwarg. | -| 81 | **#181** | Multipart/form-data outbound encoder | 3 | 3 | _fill-in_ | DEMAND-GATE | Niche multipart upload most REST/SOAP partners never ask for and a hand-built Handler body covers; a boundary encoder plus a per-request Content-Type on a connector whose type is fixed at construction (messagefoundry/transports/rest.py:1355), with the collision-checked boundary idiom already written at messagefoundry/transports/dicomweb.py:262-290 to copy. | -| 82 | **#183** | SOAP MTOM/XOP binary packaging | 3 | 3 | _fill-in_ | DEMAND-GATE | Niche IHE packaging format that base64-inline already serves for any accepting partner; XOP framing is spec-fiddly but confined to one connector's string-concatenated envelope (messagefoundry/transports/soap.py:643-702), with no body signature to disturb and the DICOMweb boundary generator to borrow. | -| 83 | **#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 | The item retracts its own product premise — the CI symptom is absorbed by #115 and a 36-run sweep shows a 1.8x-3.5x latency gap rather than a capacity cliff, leaving only an unexplained red at `rate_start = 60.0` (`tests/test_load_runner.py:150`, `pool_size = 4` at `:120`) and an unverified near-breach of the `read >= sent // 2` floor; the honest next experiment is a concurrent-load arm on the dispatch-only probe that already exists (`harness/load/ingress_probe.py`, `.github/workflows/ingress-rate-probe.yml`), not the self-hosted rig, which `ci.yml:49` records as retired. | -| 84 | **#337** | handler-security lint: `getattr` indirection and the undecorated helper | 3 | 3 | _fill-in_ | P3 | `_AMBIENT_BARE_NAMES` (`checks.py:476`) still matches a literal name chain and `checks.py` contains no `getattr` resolution at all, and the rule loop still bails on `_message_fn_decorator(node) is None` (`:937`) so the `__transforms.py` helper CONNECTIONS.md steers PHI handling into is never opened — but the lint is advisory unless an adopter opts into `--strict-handler-security`, and evading it reaches neither the DEK nor the audit chain in either sandbox posture; ~15 lines splicing a constant into `_dotted_call_name` plus a `phi-to-log` widening that must be recalibrated against the two shipped sample helpers before it lands. | -| 85 | **#110** | DICOM Study/Series Instance UID de-duplication on the C-STORE SCP | 3 | 4 | _fill-in_ | DEMAND-GATE | Niche DICOM-only study collapse most partners never need, and the SR→HL7 case can already filter to SR objects code-first because `DicomPeek` exposes both UIDs (`messagefoundry/parsing/dicom/peek.py:105-106`), though no pure Router can hold the cross-message state; the remainder is a connector-side seen-UID ledger modelled on the existing durable `processed_files` precedent (`messagefoundry/store/base.py:844`, `prune_processed_files` at `:857`) plus an explicit FILTERED disposition on the suppressed 2..N objects at `_on_c_store`/`_commit` (`messagefoundry/transports/dicom.py:273`, `:368`), tested on all three backends. | -| 86 | **#113** | Outbound source-IP binding for sender connections | 3 | 4 | _fill-in_ | DEMAND-GATE | Niche interop knob only a source-IP-allowlisting partner on a multi-homed host needs, and OS routing already settles egress selection for everyone else; the bind must reach five dial sites — `transports/tcp.py:189`, `mllp.py:849`, `x12.py:158` via `asyncio.open_connection`, `remotefile.py:259` ftplib and `:396` paramiko, which takes a pre-bound `sock=` rather than a kwarg — plus the TOML/edit allowlists. | -| 87 | **#182** | Per-message base-address override for web-service senders | 3 | 4 | _fill-in_ | DEMAND-GATE | Niche sender-control knob with a clean one-connection-per-address fan-out, and its own severity note rates it minor; the difficulty is a per-message carry key on the ALREADY-SHIPPED ADR 0081 metadata channel — a reserved `http.url`-style key read where `outbound_headers_from_metadata` is read today (rest.py:1373) — plus wiring `consumes_metadata` onto SOAP and a delivery-time SSRF/egress re-check across three HTTP clients. No new store column and no 3-backend change. | -| 88 | **#131** | Object flagging - mark objects of interest + a Flagged Objects filter | 3 | 7 | _money pit_ | DEMAND-GATE | Difficulty 7 is right — ADR 0007's amendment declines the universal flag precisely because it needs a name-keyed annotation table across all three store backends, which is literally D7 ("a new ADR plus a 3-backend migration"). Value 2 is not: it rests on "connections are the objects an operator actually lists and filters, leaving only a marker on Routers/Handlers", and that understates the remainder. I read the write path: `Engine.set_connection_flag` (pipeline/engine.py:1401) raises WiringError when the connection is not in connections.toml — "a CODE-FIRST connection has no TOML home, so the console flag is refused there" — and api/app.py:1969-1972 maps that to 409. So the shipped half serves only TOML-managed connections, while this project's default authoring mode for connections is code-first Python, and this item's own Trigger names "an adopter with a LARGE CONFIG REPOSITORY" — exactly the case the shipped half refuses. The remainder is therefore a console-settable flag for code-first connections AND Routers/Handlers, not a cosmetic residue, so it is not "already substantially covered" (=2); it is reduced-scope console polish with partial coverage. Quadrant stays money pit; tier stays DEMAND-GATE per the verdict line. | -| 89 | **#214** | Intra-message concurrent transform of a message's routed rows | 3 | 8 | _money pit_ | P3 | Marginal residual on a lever an Accepted ADR closed — the transform-overlap half is merged and tested (`_process_routed_batch`, wiring_runner.py:5311), and ADR 0107 (Accepted 2026-07-13, 'authorizes no build. Do not build F2 or F3') bounds the ENTIRE `2H` transaction term this residual removes: arm E measured a ×2.95 swing in committed txn/msg moving throughput −11.7%, elasticity d(ln throughput)/d(ln txn) = −0.115, capping the residual's absolute best case at +13.2% at H=8; the remainder is still a batched multi-row `transform_handoff` on the stage handoff itself, ADR-gated, preserving claim→produce→complete atomicity on three backends. | -| 90 | **#155** | Server-to-server migration runbook | 2 | 1 | _fill-in_ | DEMAND-GATE | Every constituent step already ships documented — install, backup/restore/DR, decommission at `docs/EARLY-ADOPTER-GUIDE.md` §4/§10/§16 — so the gap is prose stitching, not capability; one new doc that orders them end-to-end, no code. | -| 91 | **#322** | Synthetic leak-gate placeholders can collide with the real gate's own guards | 2 | 1 | _fill-in_ | P3 | The scanner ALREADY emits the diagnostic this item asks for. `scripts/security/scan_forbidden.py:846-856` prints a three-state banner to stderr on every run, before any refusal: `[STRUCTURAL-ONLY: no token source configured]`, `[SYNTHETIC EXAMPLE TOKENS — blind to real customer tokens; CI is authoritative]` (when `is_synthetic_token_set()`), or nothing — alongside `loaded_token_counts()`. `scan-tokens.local.txt.example:23-25` documents that label as the intended discriminator in the very header the item quotes: "The scanner LABELS this set on every run … the label is what does." So the scorer's load-bearing premise — a synthetic-set contributor is hard-blocked "with no diagnostic" — is false at HEAD, and the second half of the item's Proposed ("optionally have the scanner's hit message name the loaded set, so a synthetic false positive is self-diagnosing") is substantially already covered; only its placement (load banner vs. per-hit reason) differs. What genuinely remains is a guidance paragraph in `scan-tokens.local.txt.example` telling a contributor not to build a tracked placeholder from any `[site_prefix]` value in either token set. That is value 2 ("marginal, already substantially covered") and difficulty 1 ("a default flip or doc edit"). Quadrant stays fill-in; tier stays P3, so the ranking impact is ordering within P3, not scheduling. | -| 92 | **#116** | File-size integrity re-check before disposition | 2 | 2 | _fill-in_ | DEMAND-GATE | Marginal additive hardening — the `min_age_seconds` quiescence window (`transports/file.py:728`) plus the single-shot whole-file read already close the partial-write hole this guards; a re-stat before move/delete in FileSource and RemoteFile is a small additive change on an existing seam. | -| 93 | **#135** | Configurable statistics push / refresh interval | 2 | 2 | _fill-in_ | DEMAND-GATE | Marginal tuning knob with no interop dimension — the fixed cadence serves live monitoring fine and no deployment has reported console bandwidth as material; the build is a validated settings field read by the push loop, where the cadence is a single `await asyncio.sleep(1.0)` at `messagefoundry/api/app.py:4945` and `config/settings.py:701` already carries the sibling `ws_allowed_origins`. | -| 94 | **#173** | Segment/segment-group subtree-copy helper | 2 | 2 | _fill-in_ | DEMAND-GATE | One-call sugar over an API that already does the hard part — `groups()` hands back the span view (`messagefoundry/parsing/message.py:470`) and `add_segment` grafts lines (`:377`), so the 'find the group boundary' boilerplate the item cites is mostly already solved; a small additive helper whose only subtlety is re-encoding across two messages' MSH separators. | -| 95 | **#174** | Scheduled automatic statistics reset | 2 | 2 | _fill-in_ | DEMAND-GATE | Manual re-snapshot ships (`Engine.reset_stats`, `messagefoundry/pipeline/engine.py:1772-1792`, behind `POST /statistics/reset` at `messagefoundry/api/app.py:2208`) and OTel covers daily volume, so a timer is convenience only; it assembles two shipped primitives — the ADR 0095 timezone-aware `Schedule` and the #160 stdlib cron evaluator — against an existing call. | -| 96 | **#84** | Diagnostic panes — hex body view + HL7-aware before/after diff + profiling/coverage | 2 | 3 | _fill-in_ | DEMAND-GATE | Substantially covered — hex, HL7-aware diff and coverage/profiling panes all ship, so what is left is a true-binary dump nobody is blocked on; the remainder is no longer client-side-only, since the dry-run read path must first surface the wire bytes the pure pane deliberately cannot recover (`ide/src/hexdump.ts:5-10`). | -| 97 | **#156** | Alert hysteresis (separate fire/clear thresholds) | 2 | 3 | _fill-in_ | DEMAND-GATE | Anti-flap refinement the shipped `realert_seconds` / per-rule `cooldown_seconds` throttle already damps (`messagefoundry/config/settings.py:2678`, `:2823`), with single-sided `min_depth`/`min_oldest_seconds` matching confirmed at `messagefoundry/pipeline/alert_sinks.py:617-623`; two new AlertRule fields plus clear-edge state in the sink, no store or migration. | -| 98 | **#105** | Deterministic Corepoint-import tooling — Action-List → code-first scaffold | 2 | 4 | _fill-in_ | DEMAND-GATE | The adopter already hand-ported and the AI `/migrate` covers the rest, with no named demand, so it ships little worth even if finished; the mapper and CLI are built, leaving reconciliation of the emitted mapping against a real Corepoint export and the deferred `ide/` wrapper — behind #313's multi-message Handler model, which this item cannot buy. | -| 99 | **#122** | Corrupted application-log detection, rollover, and connection-stop | 2 | 6 | _money pit_ | DEMAND-GATE | Value 2 stands — stdout + NSSM rotation, the RFC 5425 TLS syslog forwarder (`_TlsSysLogHandler`, logging_setup.py:281) and #50's disk metering already carry log durability and visibility, so this is marginal and substantially covered. But difficulty 5 prices the wrong shape of work. D5 is "a new connector/codec behind the transport registry" — this is not a connector. logging_setup.py's module docstring (lines 3-13) records that the engine "deliberately do[es] not add file handlers here" because NSSM owns rotation, and `grep FileHandler | -| 100 | **#64** | Throughput parity with Corepoint — measure-first performance roadmap (group-commit + lean-writes) | 1 | 1 | _fill-in_ | P3 | An index over levers that live in #62/#63/#47/#34, so it ships nothing runnable of its own, and the remainder is reconciling roadmap prose against a measurement that has already run and a lever already abandoned — a doc edit. But the gate this item was demand-gated ON has FIRED (ADR 0051 measure-first complete 2026-07-12; ADR 0099 → ABANDON; ADR 0107 closes Phase 4), so the DEMAND-GATE override no longer applies and the tier derives from the score: P3, fill-in. | -| 101 | **#238** | OpenFlow step-attribute completeness pass over the engine vocabulary | 1 | 1 | _fill-in_ | P3 | Ships nothing runnable — the output is a findings note, and the item itself concedes most attributes are already covered engine-side under other names (retry/timeout in connector and delivery semantics), with OpenFlow compatibility explicitly declined under ADR 0076 §7 and #26; a read of seven attributes against the vocabulary and a short write-up. | -| 102 | **#352** | Consult on enterprise AV coverage for SFTP- and file-connector ingest from outside the domain (ASVS 5.4.3 premise check) | 1 | 1 | _fill-in_ | P3 | The scan seam is real — `set_scan_hook` at `transports/file.py:802`, `scan_inbound_file` at `:828`, called via `asyncio.to_thread` from `transports/remotefile.py:901` — so the citations hold. The scoring does not. The rubric's value floor is written for exactly this item: `1` ships nothing runnable. The scorer's own why closes with "the deliverable is one conversation and its recorded answer, no code", which is self-refuting against a value of 6 ("real gap, awkward workaround" — there is no gap being closed here and nothing to work around; there is a question being asked). Worth-if-built for a consult item is the answer, and the answer alone changes no shipped behaviour; if it comes back "no", the WORK that follows (reopening 5.4.3, or shipping an ICAP-backed scan control) is a different, unfiled item that would carry its own score. Difficulty 1 is right. At value 1 the quadrant is fill-in and the tier is P3; the verdict "consult, then decide" is not one of the three DEMAND-GATE verdicts, so no override applies. | +| 48 | **#1020** | The first-run bootstrap Administrator is created with no email address, and the PHI notification gate cannot see it | 5 | 3 | _fill-in_ | P2 | `_ensure_bootstrap_admin` calls `create_user` with no `email=` (`auth/service.py:527-533`), so the account holding `frozenset(Permission)` has a NULL email and `SecurityEventNotifier.notify` returns before enqueueing (`pipeline/security_notify.py:130`), making all ten notice types no-op for it — including LOGIN_AFTER_FAILURES, the compromise signal. The part with teeth is gate blindness: the PHI startup gate refuses to serve without a notification channel but computes readiness from `notify_security_events` plus `email_smtp_host` plus `email_from` alone (`__main__.py:2260`), so it would report a healthy channel while no notice about the all-permission account could be delivered. The allocated title's second half is REFUTED and the body says so: lockout is time-bounded (15 minutes by default, `settings.py:1769`), an admin reset clears it (`store/store.py:7693`), the documented break-glass is a sealed file (`api/app.py:5104`), and no email-driven recovery flow exists anywhere in the code. Value 5 because every event is still an audit row surfaced by `GET /me/security-events`. Difficulty 3 with no schema change; the only judgment is which of three candidate fixes the owner wants. | +| 49 | **#1019** | install-selfheal.ps1 has no installed-vs-source payload-parity instrument, and it wires the most privileged hook in the estate | 5 | 3 | _fill-in_ | P3 | The installer copies `worktree-selfheal.ps1` to `~/.claude-hooks/` and wires it as a user-scope SessionStart hook with no divergence detection: no `-Status` (the surface is `-ConfigDir` plus `-HookPath`), no version stamp, a bare `Copy-Item -Force` (`:57`), and no test that reads the installed copy — every selfheal test binds `ROOT` under a synthetic home, and the repo's only installed-vs-source parity test names the gate copy alone (`tests/test_gate_installed_parity.py:44`). The title's "no parity instrument at all" is narrowed in the body: source-level guard parity DOES exist (`test_both_installers_carry_the_same_refusal`), as do the CLAUDECODE refusal, the backup-validate-rollback, and an unconditional payload refresh — what is absent is payload parity. The claimed comparator is also wrong, and PR #191 has since sharpened this: it landed payload parity on `install-git-hooks.ps1` (SHA256, IN SYNC/STALE, plus a pytest-side assertion), so `install-selfheal.ps1` is now the ONLY installer in the estate without one, with two worked examples to copy. Privilege is substantiated: user scope in every config dir, and a hook that runs `git checkout` on the shared primary unattended (`worktree-selfheal.ps1:106`). Measured 2026-08-04, installed and source agree (`c41c70ecf885`), so detection is absent rather than divergence present. Difficulty 3 with four constraints, chiefly folding CRLF exactly as the existing instruments do. | +| 50 | **#236** | Test-this-step and test-up-to-step with pinned upstream values | 5 | 4 | _fill-in_ | P2 | Real debug breadth — whole-handler traced values already fold onto rows (`mergeLiveValues`, ide/src/stepsModel.ts:544) so partial runs are a convenience, but pinning an expensive `db_lookup`/`fhir_lookup` has no equivalent at all; largely a stop condition plus state dump on ADR 0072's shipped trace, with the lookup mock and keeping `buildLensTraceArgs` (:674) incapable of emitting `--show-phi` the real work. | +| 51 | **#1022** | disable_mfa has no last-factor guard where delete_webauthn_credential does, so the two removal paths can be ordered to reach zero factors | 5 | 4 | _fill-in_ | P2 | `delete_webauthn_credential` computes `last_second_factor` and refuses when MFA is required (`auth/service.py:2426-2432`); `disable_mfa` does nothing between `get_user` and `disable_totp` (`:2086-2087`), so a user holding TOTP plus one passkey can delete the passkey (permitted while `totp_enabled` is True) and then disable TOTP, arriving at zero enrolled factors — the state ADR 0068 AC-10 says the system shall refuse and `docs/SECURITY.md:752` asserts unconditionally. The consequence is overstated in the obvious reading and the body corrects it: login enforcement is NOT missing, since `mfa_verified=not mfa_required` (`:718`) plus the ASVS 6.3.3 access gate in `require()` (`api/security.py:224-234`) make the outcome a forced re-enrollment rather than single-factor access. Value 5: genuine, demonstrable, defeats a numbered acceptance criterion by ordering and makes a shipped doc guarantee untrue, but no bypass and no PHI consequence. Difficulty 4 because the raise needs mapping at two call sites that would otherwise 500, one existing test breaks by construction (`tests/test_mfa.py:189`), and two docs move with it. Filed because ADR 0068 line 140 promised this parity follow-up and no item carries it. | +| 52 | **#165** | DB schema browser + ad-hoc query runner | 5 | 5 | _fill-in_ | DEMAND-GATE | Corepoint-parity authoring aid whose external-SQL-client workaround is fully clean — the only DB reach today is the `SELECT 1` reachability probe (`messagefoundry/transports/database.py:484-501`) and dry-run refuses `db_lookup` (`messagefoundry/pipeline/dryrun.py:570`); the build is a net-new API surface plus per-dialect introspection, read-only statement gating, a permission, audit and a console pane. | +| 53 | **#232** | Steps view for routers | 5 | 5 | _fill-in_ | P2 | Real Steps-view breadth gap exactly where destination selection is decided, with a workaround — read a five-line guard-and-return — clean enough to hold it off the top; a `route` row kind widens the ADR 0076 §3 grammar, so an amendment lands first, then `return []` disambiguation in a lens that skips routers outright today (messagefoundry/lens.py:306, :344-347), a router palette, and byte-stable rewrite parity. | +| 54 | **#78** | Custom message-definition data model + conformance validator; NCPDP codec | 5 | 6 | _money pit_ | DEMAND-GATE | Corepoint-parity persisted-definition model plus a report-only validator and an additive NCPDP codec, all cleanly worked around today by a code-first Handler, so useful breadth rather than a blocker; the whole scope is still remainder — NCPDP appears nowhere in `messagefoundry/` and `profile` is merely "reserved for a conformance-profile" (`messagefoundry/parsing/validate.py:56`) — spanning a new stored model the code reads, a validator, and a new codec class. | +| 55 | **#85** | Cloud object-store + generic message-bus destinations | 5 | 6 | _money pit_ | DEMAND-GATE | Corepoint-parity transport breadth with a clean workaround — the pluggable destination registry lets an adopter write the connector code-first — and nothing exists today (`transports/` carries no object-store or bus driver; `pyproject.toml` names no boto3/azure/google-cloud/kafka dependency). But the scored remainder is the whole scope: four-plus drivers, four vetted dependencies through the hash-locked lock file, plus credential sourcing and egress allow-listing on each, which exceeds the single-connector band 5. Quadrant becomes money pit. | +| 56 | **#127** | Web-proxy credential types (Basic / Digest / NTLM / Windows) | 5 | 6 | _money pit_ | DEMAND-GATE | Breadth with a clean, ADR-ratified workaround — `cntlm` in front of the engine covers the enterprise NTLM proxy, and Basic already tunnels through `CONNECT`; the remainder is not a knob but a keep-alive HTTP client under `transports/rest.py`, because `urllib.request` opens a new connection per `open()` and the NTLM type1/2/3 handshake is connection-bound — the refusal is asserted at `messagefoundry/transports/rest.py:993-997` for the same reason #65 scoped it out (`transports/http_auth.py:27-31`), across four connector factories plus an ADR 0126 amendment. | +| 57 | **#342** | Sandbox worker kill does not reap a grandchild holding the response pipe | 5 | 6 | _money pit_ | P2 | Build state confirmed open: `pipeline/sandbox.py:327` is a bare `proc.kill()` and the module contains no `creationflags` and no `start_new_session`. Value 5 holds — #339's per-dispatch `secrets.token_hex(16)` really does bound this to availability and orphan accumulation on an opt-in posture. Difficulty 5 is the error, and the scorer's own why states the disqualifying fact: the fix "wants verifying on the Windows CI leg". The rubric prices `6` as "cross-cutting ... or Windows-CI-gated", and `5` as "a new connector/codec behind the transport registry" — which this is not. On top of the CI gate, the Windows half has no stdlib API (a kill-on-close job object means ctypes against `CreateJobObject`/`SetInformationJobObject` or a vetted new dependency), and the POSIX half is a different mechanism (`start_new_session` + `killpg`), so it is two platform implementations plus a platform-gated test. At value 5 / difficulty 6 the quadrant is money pit, not fill-in; tier stays P2 (value >= 5). | +| 58 | **#62** | Binary body carriage — store ciphertext / raw bodies as `VARBINARY`/`BLOB`/`bytea` instead of base64-in-`NVARCHAR` | 5 | 7 | _money pit_ | DEMAND-GATE | Corepoint-class ~60% at-rest win on SQL Server where the only workaround is a bigger disk, but it is measure-gated and never load-bearing on correctness; a carriage format change that re-opens ADR 0028's NUL-safe str/TEXT decision, needs its own ADR, and drags a dual-read migration over three backends and two live `mfenc:` versions. | +| 59 | **#130** | Message queues shared by name across connections + shared-name delete protection | 5 | 8 | _money pit_ | DEMAND-GATE | Parity breadth with a clean workaround — the name-wired graph already fans a router across handlers and a handler across outbounds, and nothing (zero `shared_queue`/`queue_name` hits in `messagefoundry/`) suggests a named queue is needed to express a real feed; building it adds a store seam keyed by name rather than connection, competing consumers claiming under per-lane FIFO, and reference-counted delete, on all three backends without letting the abstraction become the "channel" element CLAUDE.md forbids. | +| 60 | **#137** | Configurable server display name in the operator console | 4 | 2 | _fill-in_ | DEMAND-GATE | Value 4 is right (console polish; the URL/port already disambiguate, and monitoring.py:508 already renders a "Node id" row, so nobody is blocked), and the stale-module finding is right — there is no messagefoundry/console/, and the live title is `el("title", f"{title} — MessageFoundry")` at _html.py:171. But D2→3 rests on a false premise: "the console never imports the engine, so the label has to ride an API status response rather than being read from settings in-process". The console does not import the engine, yet the engine INJECTS a typed bundle into it at mount time — `mount_ui(app: FastAPI, deps: UiDeps)` (messagefoundry_webconsole/mount.py:69), and `UiDeps` (messagefoundry/api/_ui_seam.py:199) already carries settings-derived display values of exactly this shape, e.g. `organization_domains` (:224) and `oidc_authorization_host` (:231-234), the latter documented as "Derived from settings, never from request input". A server display name is one more UiDeps field plus a read in `page()` — no HTTP boundary crossing, no status-response plumbing. That is D2, "small additive change on an existing seam". Quadrant stays fill-in; tier stays DEMAND-GATE. | +| 61 | **#167** | Test Bench metadata seeding | 4 | 2 | _fill-in_ | DEMAND-GATE | IDE Test Bench DX input to seed the per-message metadata bag for transform tests; nobody is blocked, and the seam is small — a `--meta` flag threaded through `dry_run`/`route_message` (`messagefoundry/pipeline/dryrun.py:512-521`, `:702-709`) into the Test Bench's CLI-only channel (`ide/src/testBench.ts:240`). The bag itself already shipped (#150/ADR 0081, `messagefoundry/config/wiring.py:2604`) but write-only — no `meta_get` on `Message` — which is a clause of this item's OWN trigger, so it holds the tier at DEMAND-GATE without discounting worth-if-built. | +| 62 | **#171** | Runtime log-verbosity control + in-product log viewer | 4 | 2 | _fill-in_ | DEMAND-GATE | Ops convenience whose live-incident use case the built API half already answers — `set_runtime_level`/`current_log_level` (`messagefoundry/logging_setup.py:429`, `:452`) behind `GET`/`PATCH /logging/level` and `GET /logs/tail` (`messagefoundry/api/app.py:4566`, `:4580`, `:4609`); the remainder is pure wiring, since the console JS is already written (`messagefoundry_webconsole/static/app.js:1252`, `:1294`) and only needs a page builder to emit its attributes plus the two absent `/ui` routes and a golden-surface update. | +| 63 | **#177** | Effective-permission inspector for a user | 4 | 2 | _fill-in_ | DEMAND-GATE | The endpoint shipped (`GET /users/{user_id}/permissions`, `messagefoundry/api/auth_routes.py:610`), so the manual `/users`×`/roles` cross-ref the 5 priced is already gone and the remainder is console polish over a built surface; an apiclient wrapper plus a card on the existing `/ui/users/{user_id}` page — whose builder renders only profile/roles/scope/actions (`messagefoundry_webconsole/pages/admin.py:152-158`) — and a golden-surface update. | +| 64 | **#228** | Steps / config search finds handlers, routers, and transforms by name (not just connections) | 4 | 2 | _fill-in_ | P3 | Authoring polish on an index that already ships — a hit opens source instead of the Steps view and send targets stay unindexed; both are small additive edits, (a) a `contextValue` on rows that already carry `elementKind`/`elementName`. | +| 65 | **#124** | Batch-export message bodies from a connection log to a file | 4 | 3 | _fill-in_ | DEMAND-GATE | Console polish now that the capability itself ships — a scripted operator exports today through the audited step-up route, leaving only the save-selected affordance; the JS is already written (`messagefoundry_webconsole/static/app.js:1380`), so the cost is emitting the `data-mf-*` attributes and row checkboxes in `pages/messages.py` and registering `/ui/messages/export` ahead of `/ui/messages/{message_id}` (`routes/core.py:468`) so the path parameter cannot swallow it. | +| 66 | **#133** | User-chosen display colour on configuration objects | 4 | 3 | _fill-in_ | DEMAND-GATE | Value 4 ("DX or console polish") is right and the stale-citation finding is right (no messagefoundry/console/ package; the live chrome is _html.py's page() head). But D3→2 rests on "a colour is that same shape [as `flagged`] plus a render", and that is false in a way this codebase enforces. `flagged` is a bool with no rendering sink; a colour is an operator-supplied STRING rendered into console markup, and the /ui CSP is `style-src 'self'` with no 'unsafe-inline' (_security.py:205, _auth.py:141, and app.css:2 states the constraint outright). An inline `style="…"` colour would simply not render, so the build must either bind a fixed palette to CSS classes shipped in app.css or add a nonce'd style mechanism the CSP does not currently grant for styles — a design decision plus value validation on untrusted config input, on top of the config-model → TOML → API → console thread. That is D3 ("a new setting into one connector"-scale work), not D2's "default flip or doc edit"-adjacent band. Quadrant stays fill-in; tier stays DEMAND-GATE. | +| 67 | **#234** | Steps view projection refreshes on save only | 4 | 3 | _fill-in_ | P3 | UX latency on an opt-in authoring surface, not a correctness gap — the rows merely lag the buffer while live values stay correctly save-gated (ide/src/stepsView.ts:327); the debounce already exists at :89, but relaxing a deliberate ADR 0076 §5 guardrail means an amendment plus proving `EditLoopGuard` holds when projection races an in-flight `lens rewrite`. | +| 68 | **#343** | Sandbox child stderr is inherited unframed into the engine log stream | 4 | 3 | _fill-in_ | P3 | The worker is still spawned `stderr=None` (`pipeline/sandbox.py:266`), so a sandboxed Handler's bytes land in the engine's own log stream unattributed and a `print()` of a body writes PHI at whatever level the operator runs — but the same `print()` under the default `mode=off` reaches the same stream, so the sandbox-specific loss is attribution and the fd-1 framing that survives on luck rather than design; a `stderr=subprocess.PIPE` relay thread through the stdlib logger (inheriting the existing PHI filters) plus a bootstrap redirect of the child's `sys.stdout`, all inside one module. | +| 69 | **#346** | The sandbox import boundary is enforced only at runtime, under an off-by-default flag | 4 | 3 | _fill-in_ | P3 | The scorer verified the item's own measurement (`FORBIDDEN_MODULES` appears nowhere under `tests/`, confirmed) and inherited its conclusion — but the conclusion is the part that is false. The item's load-bearing claim is that "a re-violation is invisible to a green suite" because the guard runs only in the child under a non-default flag. `tests/test_sandbox.py` runs REAL `mode=SUBPROCESS` sessions across roughly a dozen tests (`test_subprocess_parity_router_and_handler`, `test_subprocess_marshals_live_store_run_context`, `test_generator_router_routes_under_mode_subprocess`, `test_setstate_tuple_and_nonfinite_values_survive_mode_subprocess`, ...) — the child is genuinely spawned, since the OFF test asserts `off._proc is None` as the distinguishing property. Decisively, `test_response_view_reaches_a_sandboxed_handler` (~:617-645) drives a `CapturedResponse` through a live subprocess round-trip, i.e. the exact violation instance the item is built on would now be caught red by CI. So the compensating control is a live test file, not absent, and the residual narrows to a FUTURE codec type added without an accompanying subprocess-mode test. That is test-coverage hardening = value 4, not "real gap, awkward workaround" = 6. Difficulty 3 stands (an `ast` walker anchored on the constant, falsified against a planted import). At value 4 the tier is P3 (P2 needs value >= 5) and the quadrant is fill-in. | +| 70 | **#351** | SQL Server failover test asserts on a 0.35s wall-clock margin across a real DB round-trip | 4 | 3 | _fill-in_ | P3 | One observation on one leg, with the 2022 leg passing the same commit and a sibling PR passing both, bounds this to a marginal test whose red misattributes to whichever PR it fires on — the residual worth is settling whether #348's work at the `_acquire` chokepoint merely spent latency the test had no headroom for or tipped a real delay-predicate regression; the edit is confined to one test file, but it cannot be validated locally by default (the SQL Server leg silently skips) and must not be landed as a wider margin before the question is answered. | +| 71 | **#1018** | The raw-text gate-rule scan exists in three independent copies with nothing tying them together | 4 | 3 | _fill-in_ | P3 | The two regexes that read a gate script as text and extract every dispatched tool are implemented three times, not two: `tests/test_install_gate_wiring.py:29-39`, `tests/test_gate_installed_parity.py:55-56` and `:110-114`, and `scripts/worktree/install-gate.ps1:171-179` in PowerShell. They compute the same quantity — copy 2 reads the same file as copy 1 at `:336`, and all three return the identical 10 names — so this is duplication, not resemblance. They do NOT disagree today; the hazard is a synchronised edit, demonstrated by changing the quote pattern in one copy and observing a symmetric difference. The value is the failure direction: an under-matching copy 2 shrinks `required` (`:320`) so the wiring test passes having checked less, and an under-matching copy 3 prints no UNWIRED line — both false greens in the files written because a rule once shipped dead while 85 tests stayed green. A shared Python helper cannot absorb the PowerShell copy, so the honest end state is one helper plus a cross-language agreement test; without that second half the item reads done while two implementations still float. Value 4: tooling hygiene, no engine effect, cannot fire today, and copy 1 is already partly pinned. | +| 72 | **#166** | Server-side per-user console preferences | 4 | 4 | _fill-in_ | DEMAND-GATE | Roaming console settings stay polish nobody is blocked on; the cost the 6 priced is gone — the Qt half is retired and #151 already shipped the owner-keyed per-user store + route template (`messagefoundry/store/store.py:1667-1681`), so the remainder is a second additive table across three backends plus web-console wiring, no pipeline. | +| 73 | **#235** | Generate Steps view parameter forms from Python type hints | 4 | 4 | _fill-in_ | P3 | Authoring polish — the recognized row set is unchanged and only the widgets get richer over the literal-only slots the lens marks today (messagefoundry/lens.py:255); a stdlib `inspect` schema emitter beside the 315-line `actions.py` plus replacing the hand-rolled per-op rendering in a 2,328-line model (`ADD_MENU_CATALOG`, ide/src/stepsModel.ts:886). | +| 74 | **#237** | Per-argument input modes (static templated dynamic) in the Steps view | 4 | 4 | _fill-in_ | P3 | Authoring polish that renames "not editable" honestly without unlocking a new edit class — dynamic mode stays read-only in v1 by its own sketch; the value classifier is net-new in `lens.py`, then a mode selector on the same form surface #235 rewrites, sequenced behind #233. | +| 75 | **#108** | Receiver-side 'Prefer BOM if present' encoding auto-detect | 3 | 2 | _fill-in_ | DEMAND-GATE | A configured per-connection `encoding` already covers any single-encoding feed cleanly — it is plumbed through to `normalize(raw, *, encoding=…)` on the hot path (`messagefoundry/parsing/peek.py:152-162`) and accepts `utf-8-sig`/`utf-16-le`/`utf-16-be` — leaving only the niche mixed-BOM override, a niche interop knob; the remainder is a small additive sniff on the decode path, since no UTF-16 byte-order mark is detected anywhere today. | +| 76 | **#148** | X12 TA1 interchange-acknowledgement generation | 3 | 2 | _fill-in_ | DEMAND-GATE | Niche X12 knob most partners never need — the pyx12 walk yields a conforming 997/999 free (`parsing/x12/validate.py:18`, `:69`), covering the common ack, and only a contract that specifically mandates interchange-level accept/reject reaches for TA1; the build is a pure codec addition beside the existing splitter and delimiters in `messagefoundry/parsing/x12/`, which today contains no TA1 generator at all — only the outbound classifies a partner's returned TA1 (`transports/x12.py:73-74`). | +| 77 | **#184** | Serve own endpoint WSDL | 3 | 2 | _fill-in_ | DEMAND-GATE | Niche SOAP interop knob with a clean out-of-band-WSDL workaround; a configured document served off the listener's existing GET/HEAD health short-circuit (messagefoundry/transports/http_listener.py:796-797), which already returns before any ingress row. | +| 78 | **#249** | `lens graph`: mermaid and dot export formats | 3 | 2 | _fill-in_ | P3 | `graph --json` already ships (`messagefoundry/__main__.py:156-159`), so a mermaid/dot emitter is convenience over an already-complete surface rather than a capability anyone lacks; two pure-string emitters over the existing graph model, no new dependency and no seam crossed. | +| 79 | **#338** | TLS key-exchange groups are inherited, not pinned | 3 | 2 | _fill-in_ | P3 | `harden_kex_groups` still returns `None` when `set_groups` is absent, and all three restatements survive the 2026-07-29 sweep — `CONTAINER-EXPOSURE-EVALUATION.md` still says "hardened KEX groups" under a *verification* heading, `BACKLOG.md:6422` still lists 11.6.2 in #200's Closes line against PHI.md's PARTIAL, and `ASVS-L2-PHASE0-CHANGES.md:254` still presupposes a pin — but every group that gets in is forward-secret and the floor plus `harden_cipher_suites` admit nothing static, so this is documentation accuracy plus observability; three doc edits and one additive report-only `SecurityPosture` field beside `fips_attestation()`, with the two tripwire tests left alone as the 3.15 trigger. | +| 80 | **#83** | Rich file-output disposition + FTPS / SFTP variants | 3 | 3 | _fill-in_ | DEMAND-GATE | Niche file/FTP interop knobs most partners never need, and the ones that bite are transport-side where no Handler can substitute; all of it is per-driver additive on two connectors — `FileDestination` still has no append, dated-subfolder archive or header/trailer framing knob, and `remotefile` is explicit-`FTP_TLS` only with no implicit/passive toggle or keyboard-interactive auth (`messagefoundry/transports/remotefile.py:13`, `:256-262`). | +| 81 | **#98** | Kerberos SSO channel-binding (EPA) opt-in + acceptor-enforcement spike | 3 | 3 | _fill-in_ | DEMAND-GATE | Narrow EPA hardening on an opt-in in-process-TLS SSO mode nobody is blocked on, and structurally void behind a TLS-terminating proxy, so a niche interop knob at best; the acceptors are still constructed with no bindings at all (`spnego.server(service=…)` / `spnego.server()` at `messagefoundry/auth/ldap.py:300-302`, `:360-362`, with no `channel_bindings` argument or CBT knob anywhere), so the work is a spike plus one conditional per-mode flag — but the answer needs the same domain lab #99(e) is blocked on. | +| 82 | **#159** | TCP stream-until-close (no-framing) mode | 3 | 3 | _fill-in_ | DEMAND-GATE | Niche close-framed TCP interop knob: `codec_for` requires both delimiter bytes and `FrameCodec` rejects `start == end` (`messagefoundry/transports/framing.py:62-63`, `:167-170`), so connection-close framing is inexpressible today; a `framing=none` path bypasses the shared codec on the Tcp read loop (`messagefoundry/transports/tcp.py:508-515`) and the destination's write-then-close. | +| 83 | **#163** | Static-string inbound ACK | 3 | 3 | _fill-in_ | DEMAND-GATE | Canned-ACK interop knob most partners never need — `AckMode` offers only original/enhanced/none (`messagefoundry/config/models.py:98-103`) and `build_ack` always assembles MSH+MSA (`messagefoundry/transports/mllp.py:329-350`); a new mode plus a literal setting through wiring into the one MLLP listener, with the synchronous NAK path decided. | +| 84 | **#178** | SFTP cipher / KEX / MAC allow-lists | 3 | 3 | _fill-in_ | DEMAND-GATE | Niche knob a FIPS-restricted partner needs — `client.connect` passes no `disabled_algorithms` (`messagefoundry/transports/remotefile.py:396-405`), so only host-key posture is operator-configurable. Cost is a new validated operator setting into one connector, and the Scope's second clause (preferred-ordering on the SSH Transport) is not reachable through `SSHClient.connect` — it must be set on the Transport before negotiation, so `_make_client` restructures rather than gaining one kwarg. | +| 85 | **#181** | Multipart/form-data outbound encoder | 3 | 3 | _fill-in_ | DEMAND-GATE | Niche multipart upload most REST/SOAP partners never ask for and a hand-built Handler body covers; a boundary encoder plus a per-request Content-Type on a connector whose type is fixed at construction (messagefoundry/transports/rest.py:1355), with the collision-checked boundary idiom already written at messagefoundry/transports/dicomweb.py:262-290 to copy. | +| 86 | **#183** | SOAP MTOM/XOP binary packaging | 3 | 3 | _fill-in_ | DEMAND-GATE | Niche IHE packaging format that base64-inline already serves for any accepting partner; XOP framing is spec-fiddly but confined to one connector's string-concatenated envelope (messagefoundry/transports/soap.py:643-702), with no body signature to disturb and the DICOMweb boundary generator to borrow. | +| 87 | **#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 | The item retracts its own product premise — the CI symptom is absorbed by #115 and a 36-run sweep shows a 1.8x-3.5x latency gap rather than a capacity cliff, leaving only an unexplained red at `rate_start = 60.0` (`tests/test_load_runner.py:150`, `pool_size = 4` at `:120`) and an unverified near-breach of the `read >= sent // 2` floor; the honest next experiment is a concurrent-load arm on the dispatch-only probe that already exists (`harness/load/ingress_probe.py`, `.github/workflows/ingress-rate-probe.yml`), not the self-hosted rig, which `ci.yml:49` records as retired. | +| 88 | **#337** | handler-security lint: `getattr` indirection and the undecorated helper | 3 | 3 | _fill-in_ | P3 | `_AMBIENT_BARE_NAMES` (`checks.py:476`) still matches a literal name chain and `checks.py` contains no `getattr` resolution at all, and the rule loop still bails on `_message_fn_decorator(node) is None` (`:937`) so the `__transforms.py` helper CONNECTIONS.md steers PHI handling into is never opened — but the lint is advisory unless an adopter opts into `--strict-handler-security`, and evading it reaches neither the DEK nor the audit chain in either sandbox posture; ~15 lines splicing a constant into `_dotted_call_name` plus a `phi-to-log` widening that must be recalibrated against the two shipped sample helpers before it lands. | +| 89 | **#110** | DICOM Study/Series Instance UID de-duplication on the C-STORE SCP | 3 | 4 | _fill-in_ | DEMAND-GATE | Niche DICOM-only study collapse most partners never need, and the SR→HL7 case can already filter to SR objects code-first because `DicomPeek` exposes both UIDs (`messagefoundry/parsing/dicom/peek.py:105-106`), though no pure Router can hold the cross-message state; the remainder is a connector-side seen-UID ledger modelled on the existing durable `processed_files` precedent (`messagefoundry/store/base.py:844`, `prune_processed_files` at `:857`) plus an explicit FILTERED disposition on the suppressed 2..N objects at `_on_c_store`/`_commit` (`messagefoundry/transports/dicom.py:273`, `:368`), tested on all three backends. | +| 90 | **#113** | Outbound source-IP binding for sender connections | 3 | 4 | _fill-in_ | DEMAND-GATE | Niche interop knob only a source-IP-allowlisting partner on a multi-homed host needs, and OS routing already settles egress selection for everyone else; the bind must reach five dial sites — `transports/tcp.py:189`, `mllp.py:849`, `x12.py:158` via `asyncio.open_connection`, `remotefile.py:259` ftplib and `:396` paramiko, which takes a pre-bound `sock=` rather than a kwarg — plus the TOML/edit allowlists. | +| 91 | **#182** | Per-message base-address override for web-service senders | 3 | 4 | _fill-in_ | DEMAND-GATE | Niche sender-control knob with a clean one-connection-per-address fan-out, and its own severity note rates it minor; the difficulty is a per-message carry key on the ALREADY-SHIPPED ADR 0081 metadata channel — a reserved `http.url`-style key read where `outbound_headers_from_metadata` is read today (rest.py:1373) — plus wiring `consumes_metadata` onto SOAP and a delivery-time SSRF/egress re-check across three HTTP clients. No new store column and no 3-backend change. | +| 92 | **#131** | Object flagging - mark objects of interest + a Flagged Objects filter | 3 | 7 | _money pit_ | DEMAND-GATE | Difficulty 7 is right — ADR 0007's amendment declines the universal flag precisely because it needs a name-keyed annotation table across all three store backends, which is literally D7 ("a new ADR plus a 3-backend migration"). Value 2 is not: it rests on "connections are the objects an operator actually lists and filters, leaving only a marker on Routers/Handlers", and that understates the remainder. I read the write path: `Engine.set_connection_flag` (pipeline/engine.py:1401) raises WiringError when the connection is not in connections.toml — "a CODE-FIRST connection has no TOML home, so the console flag is refused there" — and api/app.py:1969-1972 maps that to 409. So the shipped half serves only TOML-managed connections, while this project's default authoring mode for connections is code-first Python, and this item's own Trigger names "an adopter with a LARGE CONFIG REPOSITORY" — exactly the case the shipped half refuses. The remainder is therefore a console-settable flag for code-first connections AND Routers/Handlers, not a cosmetic residue, so it is not "already substantially covered" (=2); it is reduced-scope console polish with partial coverage. Quadrant stays money pit; tier stays DEMAND-GATE per the verdict line. | +| 93 | **#214** | Intra-message concurrent transform of a message's routed rows | 3 | 8 | _money pit_ | P3 | Marginal residual on a lever an Accepted ADR closed — the transform-overlap half is merged and tested (`_process_routed_batch`, wiring_runner.py:5311), and ADR 0107 (Accepted 2026-07-13, 'authorizes no build. Do not build F2 or F3') bounds the ENTIRE `2H` transaction term this residual removes: arm E measured a ×2.95 swing in committed txn/msg moving throughput −11.7%, elasticity d(ln throughput)/d(ln txn) = −0.115, capping the residual's absolute best case at +13.2% at H=8; the remainder is still a batched multi-row `transform_handoff` on the stage handoff itself, ADR-gated, preserving claim→produce→complete atomicity on three backends. | +| 94 | **#155** | Server-to-server migration runbook | 2 | 1 | _fill-in_ | DEMAND-GATE | Every constituent step already ships documented — install, backup/restore/DR, decommission at `docs/EARLY-ADOPTER-GUIDE.md` §4/§10/§16 — so the gap is prose stitching, not capability; one new doc that orders them end-to-end, no code. | +| 95 | **#116** | File-size integrity re-check before disposition | 2 | 2 | _fill-in_ | DEMAND-GATE | Marginal additive hardening — the `min_age_seconds` quiescence window (`transports/file.py:728`) plus the single-shot whole-file read already close the partial-write hole this guards; a re-stat before move/delete in FileSource and RemoteFile is a small additive change on an existing seam. | +| 96 | **#135** | Configurable statistics push / refresh interval | 2 | 2 | _fill-in_ | DEMAND-GATE | Marginal tuning knob with no interop dimension — the fixed cadence serves live monitoring fine and no deployment has reported console bandwidth as material; the build is a validated settings field read by the push loop, where the cadence is a single `await asyncio.sleep(1.0)` at `messagefoundry/api/app.py:4945` and `config/settings.py:701` already carries the sibling `ws_allowed_origins`. | +| 97 | **#173** | Segment/segment-group subtree-copy helper | 2 | 2 | _fill-in_ | DEMAND-GATE | One-call sugar over an API that already does the hard part — `groups()` hands back the span view (`messagefoundry/parsing/message.py:470`) and `add_segment` grafts lines (`:377`), so the 'find the group boundary' boilerplate the item cites is mostly already solved; a small additive helper whose only subtlety is re-encoding across two messages' MSH separators. | +| 98 | **#174** | Scheduled automatic statistics reset | 2 | 2 | _fill-in_ | DEMAND-GATE | Manual re-snapshot ships (`Engine.reset_stats`, `messagefoundry/pipeline/engine.py:1772-1792`, behind `POST /statistics/reset` at `messagefoundry/api/app.py:2208`) and OTel covers daily volume, so a timer is convenience only; it assembles two shipped primitives — the ADR 0095 timezone-aware `Schedule` and the #160 stdlib cron evaluator — against an existing call. | +| 99 | **#84** | Diagnostic panes — hex body view + HL7-aware before/after diff + profiling/coverage | 2 | 3 | _fill-in_ | DEMAND-GATE | Substantially covered — hex, HL7-aware diff and coverage/profiling panes all ship, so what is left is a true-binary dump nobody is blocked on; the remainder is no longer client-side-only, since the dry-run read path must first surface the wire bytes the pure pane deliberately cannot recover (`ide/src/hexdump.ts:5-10`). | +| 100 | **#156** | Alert hysteresis (separate fire/clear thresholds) | 2 | 3 | _fill-in_ | DEMAND-GATE | Anti-flap refinement the shipped `realert_seconds` / per-rule `cooldown_seconds` throttle already damps (`messagefoundry/config/settings.py:2678`, `:2823`), with single-sided `min_depth`/`min_oldest_seconds` matching confirmed at `messagefoundry/pipeline/alert_sinks.py:617-623`; two new AlertRule fields plus clear-edge state in the sink, no store or migration. | +| 101 | **#105** | Deterministic Corepoint-import tooling — Action-List → code-first scaffold | 2 | 4 | _fill-in_ | DEMAND-GATE | The adopter already hand-ported and the AI `/migrate` covers the rest, with no named demand, so it ships little worth even if finished; the mapper and CLI are built, leaving reconciliation of the emitted mapping against a real Corepoint export and the deferred `ide/` wrapper — behind #313's multi-message Handler model, which this item cannot buy. | +| 102 | **#122** | Corrupted application-log detection, rollover, and connection-stop | 2 | 6 | _money pit_ | DEMAND-GATE | Value 2 stands — stdout + NSSM rotation, the RFC 5425 TLS syslog forwarder (`_TlsSysLogHandler`, logging_setup.py:281) and #50's disk metering already carry log durability and visibility, so this is marginal and substantially covered. But difficulty 5 prices the wrong shape of work. D5 is "a new connector/codec behind the transport registry" — this is not a connector. logging_setup.py's module docstring (lines 3-13) records that the engine "deliberately do[es] not add file handlers here" because NSSM owns rotation, and `grep FileHandler | +| 103 | **#64** | Throughput parity with Corepoint — measure-first performance roadmap (group-commit + lean-writes) | 1 | 1 | _fill-in_ | P3 | An index over levers that live in #62/#63/#47/#34, so it ships nothing runnable of its own, and the remainder is reconciling roadmap prose against a measurement that has already run and a lever already abandoned — a doc edit. But the gate this item was demand-gated ON has FIRED (ADR 0051 measure-first complete 2026-07-12; ADR 0099 → ABANDON; ADR 0107 closes Phase 4), so the DEMAND-GATE override no longer applies and the tier derives from the score: P3, fill-in. | +| 104 | **#238** | OpenFlow step-attribute completeness pass over the engine vocabulary | 1 | 1 | _fill-in_ | P3 | Ships nothing runnable — the output is a findings note, and the item itself concedes most attributes are already covered engine-side under other names (retry/timeout in connector and delivery semantics), with OpenFlow compatibility explicitly declined under ADR 0076 §7 and #26; a read of seven attributes against the vocabulary and a short write-up. | +| 105 | **#352** | Consult on enterprise AV coverage for SFTP- and file-connector ingest from outside the domain (ASVS 5.4.3 premise check) | 1 | 1 | _fill-in_ | P3 | The scan seam is real — `set_scan_hook` at `transports/file.py:802`, `scan_inbound_file` at `:828`, called via `asyncio.to_thread` from `transports/remotefile.py:901` — so the citations hold. The scoring does not. The rubric's value floor is written for exactly this item: `1` ships nothing runnable. The scorer's own why closes with "the deliverable is one conversation and its recorded answer, no code", which is self-refuting against a value of 6 ("real gap, awkward workaround" — there is no gap being closed here and nothing to work around; there is a question being asked). Worth-if-built for a consult item is the answer, and the answer alone changes no shipped behaviour; if it comes back "no", the WORK that follows (reopening 5.4.3, or shipping an ICAP-backed scan control) is a different, unfiled item that would carry its own score. Difficulty 1 is right. At value 1 the quadrant is fill-in and the tier is P3; the verdict "consult, then decide" is not one of the three DEMAND-GATE verdicts, so no override applies. | --- @@ -2690,35 +2693,6 @@ Note the item is **not** "the scanner is broken" — it is that the token *sourc **Source:** public-repo disclosure audit, 2026-08-01 (commit `f3c6d348`). The tokens were found by reading, not by the gate. ---- - -## 322. Synthetic leak-gate placeholders can collide with the real gate's own guards - -> ✅ **BUILT 2026-08-04 — the guidance half only; the “optionally…” half is deliberately NOT built.** Value **2/10** · Difficulty **1/10** · _fill-in_. **Shipped:** a `PLACEHOLDERS IN TRACKED CONTENT` block in the header region of `scan-tokens.local.txt.example`, mirrored in `CONTRIBUTING.md` and `scripts/dev/setup-leak-gate.ps1` — the script now prints the stand-in warning on **both** installs, not just `-Synthetic`, because with the real list a placeholder built from a listed prefix is an actual disclosure rather than a false positive. Three guards in `tests/test_scan_tokens_source.py`, each falsified against a planted defect: the two pure-prose files must be COMPLETELY clean under the synthetic set (not merely site-code-free — naming the synthetic placeholders outright to explain them would block a fork contributor on the very files that document the gate); the `.example` free of both site-code classes, behind a precondition that the detectors are ARMED so the emptiness cannot pass vacuously through the `_NEVER` sentinel; and the header's stated counts must equal what it compiles to **with nothing dropped by the parser** — counts alone are blind to a line that fails to compile and is discarded with only a stderr warning, verified by removing that half and watching an unbalanced-paren line inserted into `[names]` pass every test in the tree. **Three facts in the body below are wrong; the shipped text corrects them rather than copying them.** (1) “a site-code hit, twice” is two OCCURRENCES of ONE detector — `_SITE_CODE_FILE` requires four LITERAL digits and `_SITE_CODE_PATTERN_LITERAL` a quantifier or an x-run, so no single string is both; measured, a placeholder value gives exactly one `site code` hit. The useful consequence is the inverse of the one implied: fixing one form does **not** clear the other, which is the ADR 0030 miss the scanner already records. (2) `` is safe because it is **non-numeric**, not because `_HOME_PATH`'s `(?!<` lookahead exempts it — that lookahead is positional to a `/Users/`-style segment and never reaches the site-code detectors; measured, an angle-bracketed code and an angle-bracketed x-run both fire. (3) Nothing scans a commit message — the hook passes staged FILES and CI runs `--path .` — so the guidance says the opposite: there is no net behind you in a subject line. **NOT built, by lane ruling:** the per-hit reason string naming the loaded set. It would collide with a later wave on `scan_forbidden.py`, which this lane leaves untouched, and the three-state load banner the scanner already prints before any refusal covers the diagnostic need. **Corrected in passing:** `CONTRIBUTING.md` told a synthetic-set contributor their “commits will pass” — measured 2026-08-04 that set produces 649 hits across 120 tracked files, because its placeholders are the fictional customer/partner names the project's own docs and samples use throughout; the bullet now says it is a DIFFERENT detector set and to judge a hit against the run banner. _(was 4/10 · 2/10; the re-score rationale is unchanged and stands in the ranked-table row above.)_ - -**Cluster:** Security / DX. **Priority:** P3. **Verdict:** build (small). **Severity:** low. - -> **This item deliberately does not spell out the offending value.** Writing the prefix, or the prefix followed by four digits, would trip the very detector described — `[site_prefix]` builds *two* patterns from its entry: the prefix plus four digits, **and** the pattern written out as an x-run or quantifier. That recursion is the whole point of the item, so it is demonstrated rather than described. - -**What:** while redacting the #321 tokens, the first replacement chosen followed `scripts/security/scan-tokens.local.txt.example`'s own stated convention — that file designates a specific non-real numeric prefix for synthetic use, and the replacement was built from it. Under the **real** token set that value is clean. Under the **synthetic** set it is a site-code hit, twice, and the scan exits 1: - -``` -MEFOR_FORBIDDEN_TOKENS=scripts/security/scan-tokens.local.txt.example \ - python scripts/security/scan_forbidden.py --path $T -# -> docs/BACKLOG.md:: site code (x2), exit 1 -``` - -`scripts/dev/setup-leak-gate.ps1 -Synthetic` is a **documented, supported contributor setup** (the example file calls it so in its own header), and the pre-commit hook passes `--require-tokens`, so it blocks *every* commit — not just ones touching that file. A contributor with no access to the real token list would hit an unexplained hard block on unrelated work. The final commit uses the non-numeric `SITEA` instead, which cannot collide with any numeric detector. - -**Why:** the example file's synthetic-prefix guidance is written for the person filling in the **token list**, where it is correct and necessary. But it reads as general guidance for *placeholder values*, and a placeholder written into tracked prose is then scanned by the gate that list configures. The convention is self-colliding for its second audience, and nothing warns you. The same trap caught [#325](BACKLOG.md), whose worked examples had to be rewritten to the exempt `` form for exactly this reason. - -**Proposed:** state in `scan-tokens.local.txt.example` (and in the redaction guidance) that a placeholder written **into tracked content** must not use any prefix appearing in `[site_prefix]` in *either* the real or the example set — prefer a non-numeric stand-in (`SITEA`, ``), matching the `<…>` convention `_HOME_PATH` already exempts. Optionally have the scanner's hit message name the loaded set, so a synthetic-set false positive is self-diagnosing rather than reading as a real leak. - -**Related:** `scripts/security/scan-tokens.local.txt.example`, `scripts/dev/setup-leak-gate.ps1`, `.pre-commit-config.yaml` (the `--require-tokens` arm), #321. - -**Source:** public-repo disclosure audit, 2026-08-01; found by testing the redaction under both token sets before committing `f3c6d348`. - - --- ## 324. Custom role with `messages:edit` alone reads raw PHI via the `/ui` editor @@ -3144,104 +3118,6 @@ Honestly bounded: **this is build-time only.** No PHI path, no running-engine su --- -## 334. semgrep, a required blocking gate, scans a two-directory allow-list - -> ✅ **Status CLOSED (built 2026-08-04).** `semgrep --config .semgrep --error --metrics off messagefoundry tee` is now `semgrep --config .semgrep --error --metrics off --exclude … .` carrying bandit's exclude set from the same file, name-for-name, so the project's own dangerous-sink rules now cover **59** tracked `.py` files they never saw: `messagefoundry_webconsole/` (33), `scripts/` (24), `docker/` (2). **The body's "56 / 32 / 22" is a stale measurement, not a different scope** — re-measured 2026-08-04 at **339** in-scope files, up from 280. Clean at that bar (0 findings, AST emulation of all five rules); **not run with real semgrep, which has no supported Windows install** — the first CI run on the PR is the real check. `tests/test_lint_scope_parity.py` carries the parity arm the item asked for, plus three assertions it did not: no `--include` (it re-narrows the scan behind a positional `.`, so a targets-only check reads green on this very regression), no `./` prefix on a semgrep `--exclude` (a glob, not a path — and the set comparison normalises `./` off both sides), and `--error` still present (without it the widened gate prints every finding and exits 0). **Every `security.yml:NNN` anchor in the body below has moved** — the command is now at `:449`; the job still starts at `:393`. Two claims elsewhere rested on this item's old state and were corrected in the same commit: ADR 0034's residual row mitigated an unpinned `pip` bootstrap with *"semgrep is **not** a required context"* (it is — `.github/required-contexts.txt:78`), and `docs/Secure_Build_Scorecard_MEFOR.md:56` carried a now-resolved nit about the `.semgrep` header still calling the rules "advisory". - -**Cluster:** Security / CI gates. **Priority:** P2. **Verdict:** build. **Severity:** low. - -**What:** `.github/workflows/security.yml:413` runs the project SAST gate as an allow-list: - -``` -semgrep --config .semgrep --error --metrics off messagefoundry tee -``` - -It is a **required merge context** (`.github/required-contexts.txt:78`) and **blocking** (`security.yml:396`). Its sibling on the same file was moved off that shape deliberately — `security.yml:359-360` is `bandit -r .` minus an explicit `--exclude` list, and the rationale at `:348-352` names the failure by name: *"It was `-r messagefoundry tee` while the hook scanned everything except tests/harness/samples — so scripts/ (security tooling, subprocess-heavy) was gated locally and by nothing in CI."* semgrep was never given the same treatment. - -Taking bandit's exclude set as the project's own declaration of what SAST is supposed to cover, the delta is **56 tracked `.py` files**: `messagefoundry_webconsole/` (32 files, which ships as its own separately-versioned wheel — `packaging/messagefoundry-webconsole/pyproject.toml:17`, force-included at `:55-58`), `scripts/` (22 — the security tooling the bandit comment was written about), and `docker/` (2). `packaging/`'s 14 files are all tests and are excluded on both sides. - -`tests/test_lint_scope_parity.py` is cited at `security.yml:358` as the control that stops this ("fails if this and the hook drift apart again"), and it does hold that line for ruff and bandit — `test_ci_bandit_scans_the_repo_not_an_allow_list` (`:119-125`) asserts `bandit\s+-r\s+\.` against the workflow directly. **The string "semgrep" does not appear anywhere in that file.** There is no semgrep pre-commit hook either, so semgrep is CI-only and that CI-only assertion at `:119` is the exact template a semgrep arm would follow. - -**Why:** the honest blast radius is **drift, not exposure** — three things bound it, and the item is worth filing anyway: - -1. **Nothing is being missed today.** Grepping `messagefoundry_webconsole/` for every sink the five rules match (`shell=True`, `os.system`, `eval(`, `exec(`, `pickle.`, `marshal.`, `yaml.load(`, `verify=False`) returns **zero** hits. The console makes no outbound HTTP calls of its own — `routes/oidc.py` delegates code redemption to `messagefoundry.auth.oidc`, which *is* scanned. -2. **bandit is a real compensating control on the same PR.** It is also required (`required-contexts.txt:74`), it does scan all 56 files, and its built-in checks for `exec`/`eval`, pickle/marshal, `yaml.load` and `shell=True` are **not** in the `--skip` list at `:359` (that list is only `B101,B110,B311,B404,B608`). So four of the five rules have overlapping enforcement. *(Confirm the specific bandit check-id mapping before leaning on this in review; the skip list is what was read, not bandit's plugin source.)* -3. **CodeQL also covers it** — `codeql.yml:55` analyses python repo-wide with `security-extended` (`:66`) and has no paths filter. But it is **deliberately not a required context** (`required-contexts.txt:108-110`: fork-PR tokens lack `security-events: write`), so it is a detector, not a gate. - -What is *not* covered is the thing that will grow: `.semgrep/messagefoundry.yml` is where **project-specific** rules land — rules bandit will never ship. A future rule written because of something an operator hit in the console would silently not run on the console. The second step in the same job (`security.yml:414-424`, the ADR 0144 handler-taint rules) is scoped to `samples/config` only, so it does not close this either. - -**Correcting the audit that produced this item:** the source finding claimed the five rules "cover exactly the sinks that matter in an HTML-rendering console." They do not — `.semgrep/messagefoundry.yml:5-53` contains no HTML, template, escaping or XSS rule at all. The rules are generic dangerous-sink rules (`:6`, `:14`, `:21`, `:30`, `:43`). The finding also named only the web console; `scripts/` is out of scope on the same line and is the directory the bandit widening was specifically about. - -**Proposed:** - -1. Change `security.yml:413` to scan the tree the way bandit does — `semgrep --config .semgrep --error --metrics off --exclude … .` — mirroring bandit's `--exclude` set exactly so the two gates cannot disagree about what "the project" is. **Verify the tree is clean at that bar in the same PR**: `--error` is blocking, and `tests/` (563 files) very plausibly contains `pickle`/`yaml.load`/`eval` test idioms, which is presumably why `tests`/`harness`/`samples` are excluded on the bandit side too. -2. Add `test_ci_semgrep_scans_the_repo_not_an_allow_list` to `tests/test_lint_scope_parity.py`, modelled on `:119-125`, so the next narrowing has to be deliberate. This is the durable half — without it, step 1 can rot again exactly as bandit's did. -3. If a full widening is rejected, the fallback is appending `messagefoundry_webconsole scripts docker` to the argument list — but note that this is the allow-list shape `:348-352` explicitly retired, and it will go stale the next time a directory is added. - -**Related:** `.github/workflows/security.yml:348-360` (the bandit precedent) and `:393-424` (the semgrep job), `.semgrep/messagefoundry.yml`, `tests/test_lint_scope_parity.py`, `.github/required-contexts.txt:74`/`:78`/`:108-110`, `.github/workflows/codeql.yml`, `packaging/messagefoundry-webconsole/pyproject.toml`, [ADR 0065](adr/0065-web-ops-dashboard.md) (the console is a distinct distribution), [ADR 0144](adr/0144-handler-config-taint-rules.md) Inc 3 (the second, `samples/config`-scoped semgrep step). - -**Source:** public-repo disclosure audit, 2026-08-01. - ---- - ---- - -## 336. Dependabot auto-merge shields review with a deny-list - -> ✅ **SHIPPED 2026-08-04 — guardrail #3 inverted from a 16-name deny-list to an ecosystem-qualified ALLOW-SET (hold unless named); a fail-closed release-age gate added as #4.** Value **5/10** · Difficulty **3/10** · _fill-in_. §1 `.github/workflows/dependabot-auto-merge.yml` now holds any PR whose dependencies are not on their own ecosystem's allow row — `actions/`/`github/`/`dependabot/` for `github-actions`, deliberately EMPTY for `uv` and `npm`, and an unrecognised ecosystem token holds rather than merges — preserving the fail-safe whole-group denial (measured: PR #75's five-bump batch carried `pypa/gh-action-pypi-publish`, so that batch would now HOLD). §4 a new `id: age` step requires every SECURITY-track candidate version to have been published at least 24h, failing closed on an API error, an absent/unparseable upload timestamp, an unexpected name or version shape, or an ecosystem with no publish-date source wired. ⚠️ **#4 is a FORWARD guard and is INERT with respect to the merge decision as shipped** — `age_ok=true` is reachable only for `uv`/`pip`, `eligible=true` only for `github-actions`, and the merge `if` requires both, so the two sets are disjoint. It is recorded that way in the workflow header rather than as an operating control, and gated on the allow-set so it makes no unauthenticated outbound request from the `contents: write` job for a PR that holds regardless; it becomes load-bearing the day a Python allow row is populated (an owner decision) or the advisory gate is made ecosystem-aware. §3 `tests/test_dependabot_automerge_guardrails.py` now asserts a cooldown on EVERY configured ecosystem behind a vacuity floor, and executes the shipped `run:` bodies under `bash -e` — the shell GitHub Actions actually applies — rather than only reading the YAML. §5 the false-premise backstop clause is corrected rather than merely deleted: no REQUIRED check reads a dependency's shipped bytes, and `trivy`, which does read the built image's bytes, is advisory (`continue-on-error`) and cron/dispatch-only, so it never runs on a Dependabot PR at all. **§2 was ALREADY SHIPPED** by the 2026-08-03 amendment (`.github/dependabot.yml` sets `cooldown.default-days: 5` on `github-actions`) and was NOT rebuilt; **§6 is discharged by DELETING the deny-list** rather than pruning it, which removes `python-jose`/`pyjwt`/`passlib` — all three absent from `requirements.lock`'s 98 pinned distributions — along with it. - -> ⚠️ **AMENDED 2026-08-03 — the `github-actions` cooldown SHIPPED, discharging Proposed §2 in substance and half the false-premise finding with it.** The second measured-at-HEAD bullet asserts that ecosystem *"carries `schedule` + `groups` only; there is no `cooldown:` key"*, but `.github/dependabot.yml:83-84` now sets `default-days: 5` for it, with the rationale at `:75-82` (#75 took two of five bumps to `main` under 24h from publish; `codeql-action` v4.37.4 was 7h old). So the *"Bounding this honestly"* line **"Only `github-actions` is unaged"** no longer holds, and each of the three configured ecosystems now has a cooldown behind the header's claim (now at `.github/workflows/dependabot-auto-merge.yml:22-24`, not `:16-18`). ⚠️ **Read §2 as discharged in substance, not to the letter** — `.github/dependabot.yml:79-80` records that this ecosystem honors `default-days` alone and ages off the **tag's commit date**, "so treat 5 as approximate", which is why §2's *"matching uv's 5/7"* could not be met. -> -> **The deny-list itself is untouched, so the rest of the item stands:** 16 Python names at `:84-85` gating every ecosystem behind an author-only job condition (`:64`) with no ecosystem qualifier, a merge gate still keying on `version-update:semver-patch` (`:184`), `tests/test_dependabot_automerge_guardrails.py:107-108` still asserting a cooldown for `uv` alone, and `python-jose` / `pyjwt` / `passlib` still absent from `requirements.lock` — so §§1, 3, 4, 5 and 6 are unaffected, as is the Why's other leg (`.github/workflows/security.yml:261-262` still describes pip-audit as *lockfile only* and bandit/semgrep as *source only*). ⚠️ **At least four `dependabot-auto-merge.yml` citations above (`:16-18`, `:58`, `:78-79`, `:155-161`) and all four `dependabot.yml` ones now point at different lines** — re-measure before quoting one; the `tests/` and `security.yml` citations are still exact. - -> **AMENDED 2026-08-04 — one clause of the 2026-08-03 note above is superseded by the SHIPPED banner; the dated measurement itself stands and is deliberately left as written.** *"The deny-list itself is untouched, so the rest of the item stands … §§1, 3, 4, 5 and 6 are unaffected"* was accurate when measured. It is not now: the deny-list no longer exists — guardrail #3 is an allow-set — so §6 is discharged by deletion rather than annotation, and §§1, 3, 4 and 5 are built rather than merely unaffected. The 16 names survive only as a PROPERTY under test (`_DENY_PACKAGES` in `tests/test_dependabot_automerge_guardrails.py` asserts none of them reaches any allow row), not as a mechanism. - - -**Cluster:** Security / Supply chain. **Priority:** P3. **Verdict:** build. **Severity:** low. - -**What:** `.github/workflows/dependabot-auto-merge.yml` decides unattended merges by exclusion. Guardrail #3 is a hard-coded shield list at `.github/workflows/dependabot-auto-merge.yml:78-79`: - -``` -denylist="cryptography argon2-cffi argon2-cffi-bindings paramiko ldap3 pyspnego \ - fastapi starlette uvicorn pydantic pydantic-core python-jose pyjwt passlib bcrypt cffi" -``` - -Anything not on that list, on any ecosystem, auto-merges if it is a patch — the merge gate at `:155-161` keys only on `update-type == 'version-update:semver-patch'` (plus dev-only minors), with no ecosystem or dependency-type qualifier. The workflow itself already names the residual at `:31-34`: "a malicious patch that BOTH rides a real concurrent published advisory AND is not on the deny-list would still auto-merge." - -Two things measured at HEAD make the exposed set **wider than that comment implies**: - -1. **All 16 names are Python distributions, but the job has no ecosystem filter.** The only gate is `if: github.event.pull_request.user.login == 'dependabot[bot]'` (`:58`), so the npm (`/ide`) and `github-actions` ecosystems configured in `.github/dependabot.yml` run this same path with **zero** deny-list coverage — no npm or action name can ever match a Python token. -2. **The `github-actions` ecosystem has no cooldown.** `.github/dependabot.yml:42-56` carries `schedule` + `groups` only; there is no `cooldown:` key, unlike uv (`default-days: 5`, `:26-30`) and npm (`default-days: 3`, `:66-68`). That falsifies the compensating-control claim in the workflow header at `.github/workflows/dependabot-auto-merge.yml:16-18` — "Fresh-release supply-chain poisoning is handled upstream by the dependabot.yml `cooldown`" — for the one ecosystem whose artifacts execute inside CI. Actions are SHA-pinned (e.g. `actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1`, `.github/workflows/release.yml:73`), and a Dependabot patch bump rewrites that SHA with no aging and no shield. - -Two smaller corrections to the record. The deny-list is 16 names but only **13** are real dependencies: `python-jose`, `pyjwt` and `passlib` do not appear in `requirements.lock` (98 pinned distributions), so the shield covers 13 of 98. And `tests/test_dependabot_automerge_guardrails.py:97-108` asserts a cooldown only for `package-ecosystem == "uv"` — nothing tests that the other two ecosystems have one, which is why the gap is invisible to CI. - -**Why:** this is the "compensating control resting on a false premise" rule (CLAUDE.md §11) firing on a supply-chain gate. The header's stated backstop is weaker than it reads in a second way too: `.github/workflows/security.yml:261` says in the repo's own words that "pip-audit (lockfile only) and bandit/semgrep (source only)" never see a dependency's contents — semgrep targets `messagefoundry tee` (`security.yml:413`) — and the required contexts `pip-audit (dependency vulnerabilities)` / `npm-audit (ide dependency vulnerabilities)` (`.github/required-contexts.txt`) are known-advisory scanners, which by construction cannot flag a package that was malicious at publish time. So "main's required CI backstops both" (`.yml:33-34`) does not hold against the specific threat the deny-list exists to cover. - -**Bounding this honestly — the blast radius is NOT what it first looks like:** - -- **The attacker must already own an upstream publisher account.** That is a capability with worse uses than this repo; nothing here is a privilege escalation for a lesser attacker. -- **On the security track the GHSA gate is real and fails closed** (`:136-145`): an API error or a no-matching-advisory result routes to manual review. Because it queries `ecosystem=pip` (`:132-135`), npm and actions security PRs can never match and always fall to manual — a false negative in the *safe* direction. -- **On the version track the uv and npm ecosystems are cooldown-aged** (5 and 3 days). Only `github-actions` is unaged. -- **Merging to main is not publication.** `.github/workflows/release.yml:30-41` gates PyPI on a `vX.Y.Z` tag push, and `workflow_dispatch` explicitly does not publish (`release.yml:5-8`), so reaching a downstream install still requires the owner to cut a tag. The sharpest theoretical path — a poisoned action riding in `release.yml`, whose job holds `id-token: write` for Trusted Publishing (`release.yml:70`) — needs that same owner tag push to execute at all. -- **This is hardening, not an incident.** No evidence of exploitation; the item is that the control's stated premise and its configuration disagree. - -**Proposed:** - -1. **Invert guardrail #3 to an allow-set.** Replace the 16-name deny-list at `:78-79` with an explicit list of packages eligible for unattended patch merge; everything else routes to manual review. The current default is "merge unless named"; it should be "hold unless named." Keep the fail-safe whole-group denial semantics (`:81-92`). -2. **Add a `cooldown:` to the `github-actions` ecosystem** in `.github/dependabot.yml:42-56`, matching uv's 5/7. This is the one-line change that makes the header's claim at `:16-18` true instead of aspirational. -3. **Extend `tests/test_dependabot_automerge_guardrails.py:97-108` to assert a cooldown on every ecosystem**, not just `uv` — the test's narrowness is why item 2 was invisible. -4. **Security-track aging must be workflow-side, not dependabot-side.** "Extend the cooldown to the security track" is *not* implementable in `.github/dependabot.yml`: that file records at `:22-25` that security updates ignore cooldown by Dependabot design. The equivalent is a release-age check in the workflow — require the candidate version to have been published for N hours before auto-merging — placed alongside the GHSA step and failing closed the same way. -5. **Correct the header comment.** `:33-34` should stop citing pip-audit/bandit/semgrep as a backstop against a fresh malicious publish, since `security.yml:261` already states they cannot see it. Either drop the clause or scope it to "known-CVE regressions." -6. **Prune or annotate the three non-dependency deny-list entries** (`python-jose`, `pyjwt`, `passlib` are absent from `requirements.lock`) so the list's apparent breadth matches its effective breadth. If they are deliberately prophylactic, say so in the comment. - -**Related:** `.github/workflows/dependabot-auto-merge.yml`, `.github/dependabot.yml`, `.github/workflows/security.yml`, `.github/workflows/release.yml`, `.github/required-contexts.txt`, `requirements.lock`, `tests/test_dependabot_automerge_guardrails.py`, `.github/workflows/dependabot-lock-resync.yml`; #321, #322, and the unhashed release-toolchain item from this same audit (a different file and a different fix). - -**Source:** public-repo disclosure audit, 2026-08-01. - ---- - ---- - ## 337. handler-security lint: `getattr` indirection and the undecorated helper > 🔢 **Filed 2026-08-01 — not started.** Value **3/10** · Difficulty **3/10** · _fill-in_. `_AMBIENT_BARE_NAMES` (`checks.py:476`) still matches a literal name chain and `checks.py` contains no `getattr` resolution at all, and the rule loop still bails on `_message_fn_decorator(node) is None` (`:937`) so the `__transforms.py` helper CONNECTIONS.md steers PHI handling into is never opened — but the lint is advisory unless an adopter opts into `--strict-handler-security`, and evading it reaches neither the DEK nor the audit chain in either sandbox posture; ~15 lines splicing a constant into `_dotted_call_name` plus a `phi-to-log` widening that must be recalibrated against the two shipped sample helpers before it lands. @@ -4805,19 +4681,21 @@ Retiring the tree costs the engine nothing operationally: **`tests/test_ech_egre **Cluster:** Security / startup gates. **Priority:** P1. **Verdict:** build. **Severity:** high on first deployment — no authentication at all on an off-loopback PHI instance. -**Anchors, measured on `origin/main` at 32d0cef9.** These resolve today; verify them before starting. +**Anchors, re-derived on `origin/main` at 17374679 now that #326 has merged.** These resolve today; verify them before starting. -- `messagefoundry/__main__.py:1080` — `if not settings.auth.enabled and not settings.api.is_loopback:` — the auth-off arm. -- `messagefoundry/__main__.py:1881` — `admin_exposed = not settings.api.is_loopback or ui_exposed` — the pre-#326 form. -- `messagefoundry/__main__.py:2368` — `instance_exposed = not settings.api.is_loopback or settings.api.tls_terminated_upstream` — the definition that already encodes the declared-terminator case. +- `messagefoundry/__main__.py:1112` — `if not settings.auth.enabled and not settings.api.is_loopback:` — the auth-off arm. +- `messagefoundry/__main__.py:1917` — `instance_exposed = not settings.api.is_loopback or settings.api.tls_terminated_upstream` — the definition that already encodes the declared-terminator case, and now the ONLY one. +- `messagefoundry/__main__.py:1939` — `admin_exposed = instance_exposed` — #326's post-fix form, re-keyed onto the definition above. -**The separation is the reason this is a separate item and not a one-line follow-on to #326.** `instance_exposed` is defined **1,288 lines BELOW** the auth-off arm, so the arm cannot reference it without hoisting the definition. #326 could re-key `admin_exposed` because the definition already sat above it; this cannot. +**The separation is the reason this is a separate item and not a one-line follow-on to #326.** `instance_exposed` is defined **805 lines BELOW** the auth-off arm, so the arm cannot reference it without hoisting the definition. #326 could re-key `admin_exposed` because the definition already sat above it; this cannot. **Why it is arguably worse than #326.** #326 was single-factor admin over the network. This is **no factor at all**. A deployment that follows the documented off-loopback topology, with a declared terminator and `[auth] enabled=false`, starts silently. ⚠️ **THE REMEDY IS UNPROVEN — do not read this item as prescribing one.** Nobody has established that hoisting `instance_exposed` to the auth-off arm is safe. That arm runs **early** in the startup ladder, and whether the settings it reads are fully resolved at that point is unknown. **That ordering question is the actual work of this item**, not the two-line re-key it superficially resembles. -**After #326 lands** (branch `plan-cli-exposure`, unmerged at filing) the anchors move: the arm sits at `:1112`, `instance_exposed` at `:1917`, `admin_exposed` at `:1939`, and the separation narrows to **805 lines**. That lane also **deletes the duplicate definition at the former `:2368`** and leaves a pointer comment, so afterwards there is exactly ONE definition site to move rather than two to keep in sync. Re-verify the anchors against `main` once it merges. +**#326 HAS LANDED** (PR #189), and the re-verification this paragraph asked for was performed at `17374679`: the arm moved `:1080` to `:1112`, `instance_exposed` moved `:2368` to `:1917`, `admin_exposed` is now `admin_exposed = instance_exposed` at `:1939`, and the separation narrowed from 1,288 lines to **805**. The duplicate definition at the former `:2368` is **gone**, replaced by a pointer comment at `:2454` ("`instance_exposed` is NOT re-derived here. It is defined ONCE, above"), so there is now exactly ONE definition site to move rather than two to keep in sync. **The load-bearing property survives the move and so does the difficulty-4 pricing:** the arm at `:1112` still sits ABOVE the definition at `:1917`, so it still cannot reference it without hoisting, and the ordering question is still the actual work. Only the numbers changed. + +⚠️ **A consequence of #326 that this item does not cover, and that no gate can see.** Re-keying `admin_exposed` onto `instance_exposed` means the MFA-at-exposure refusal now fires on a declared-TLS-terminator topology where it previously could not — a posture change under **ASVS 6.3.3**, whose citations all still resolve, so nothing went red. Raised by the vault drift-repair pass of 2026-08-04; 6.3.3 needs re-validating against the code rather than being assumed still correct. Not folded in here. **Related:** #326 (the sibling arm, same file, same gate family), #328. The ADR 0140 amendment on `plan-cli-exposure` records this residual but names no number, having been written before one existed — worth a follow-up edit now that this item is filed. @@ -4890,3 +4768,149 @@ Retiring the tree costs the engine nothing operationally: **`tests/test_ech_egre **Related:** #340 (merge-queue serialisation — the other place this repo's parallelism outgrew a fixed assumption). **Source:** found by a build session while re-verifying five rebased lanes, 2026-08-04. It attributed the immediate trigger to its own parallel harness rather than to the branches under test, and handed the underlying defect over because filing needs a number and a ranked-table row. + +## 1021. The MFA enrollment confirm verifies the activating TOTP through a bool wrapper that discards the step, so it is never consumed (ASVS 6.5.1) + +> 🔢 **Filed 2026-08-04 — not started.** Value **6/10** · Difficulty **4/10** · _quick win_. `confirm_mfa_enrollment` verifies the enrolling code with `totp.verify_totp`, a documented thin bool wrapper that computes the matched time-step and then collapses it to a bool, so the step cannot be recorded. `enable_totp` leaves `last_totp_step` NULL, and `consume_totp_step` rejects only when `last is not None and last >= step` — so on first deployment the activating code would remain usable on the login path for the remainder of its own step. + +**Cluster:** Security / authentication. **Priority:** P2. **Verdict:** build (small). **Severity:** would leave a narrow second-factor replay window at enrollment on first deployment — bounded, not a bypass. + +**Two facts combine, and the body needs both.** The confirm path discards the step (`auth/service.py:1979` calls `totp.verify_totp`; `auth/totp.py:150` computes the step then returns `... is not None`), and nothing seeds the high-water mark, so the discarded step is genuinely reachable rather than incidentally blocked: `enable_totp` updates only `totp_enabled`, `totp_enrolled_at`, `totp_recovery_codes`, `updated_at` in all three backends (`store/store.py:7752-7764`, `sqlserver.py:9095`, `postgres.py:6165`), leaving `users.last_totp_step` NULL, and the compare-and-set at `store/store.py:7824` accepts any matched step against a NULL mark. + +**The replay target is the login path, not a second confirm.** Code `C` proven at `POST /me/mfa/confirm` would still be accepted by `POST /auth/mfa-verify` on a separate, password-authenticated session for the same account. `totp_skew_steps` defaults to `0` (`config/settings.py:1736`), so the window is the remainder of `C`'s own 30-second step — roughly 60 or 90 seconds only under the documented 1/2 opt-in. Do not size it as plus-or-minus-one step. `confirm_mfa_enrollment` also lacks a `totp_enabled` guard, so a second confirm would re-succeed, but that route needs a fresh action-bound password step-up (`api/auth_routes.py:408`) and is the lesser path — do not build the fix around it. + +⛔ **The replay guard already exists. Do not rebuild it.** `verify_totp_step` already returns the matched step and already clamps a tolerated fast-clock code down to the current step (`auth/totp.py:90-132`, SEC-014); `_verify_second_factor` already does verify-then-consume on the login path (`auth/service.py:2061-2073`); the atomic compare-and-set exists in all three backends (`store/store.py:7811-7828`, `sqlserver.py:9155-9177` with UPDLOCK/ROWLOCK, `postgres.py:6217-6231` with FOR UPDATE), declared at `store/base.py:1588`; and login-path single-use is pinned by `tests/test_mfa.py:139`. **The only thing missing is the call at the enrollment site.** Note also that `disable_totp` leaves `last_totp_step` untouched — that direction is conservative and must not be "fixed" by clearing it. + +**Difficulty 4, and the cost is test collateral rather than code.** The production change is about three lines: switch `:1979` to `verify_totp_step`, keep the step, and require `consume_totp_step` before activating — consuming **before** `enable_totp`/`mark_session_mfa_verified`/minting recovery codes, and treating a `False` as a failed confirm on the existing `auth.mfa_failed` phase=enroll branch. At least four tests confirm an enrollment then assert a live verify inside the same step and would go failing or intermittently failing: `tests/test_mfa.py:81-94`, `:147-157` (sharpest — it reuses the same code object), `:272-281`, and `tests/test_step_up.py:314-318`. The obvious remedy does not work: `tests/_totp_clock.py`'s `fresh_totp` guarantees headroom **within** the current step and cannot advance one, so each affected test needs restructuring rather than a CI sleep across a 30-second boundary. + +**Both operator surfaces reach this through the one service method** — `POST /me/mfa/confirm` (`api/auth_routes.py:403-427`) and `POST /ui/account/mfa/verify` (`messagefoundry_webconsole/routes/account.py:239-272`) — so fixing the service method fixes both and no route change is needed. + +**Open question, not a blocker:** whether any security document states TOTP single-use in terms broad enough to be made inaccurate by this gap. `docs/BACKLOG.md:698` describes the per-user compare-and-set and is true as written. The vault scorecard was not readable from this checkout, so if 6.5.1 is scored fully met there, that cell needs re-validating against the code rather than being assumed still correct. + +**Source:** found during the ASVS V6 re-verification, 2026-08-04, and adversarially re-verified against the code at `6e481c14` before filing. Confirmed as stated. + +## 1017. worktree_gate rule 3d has no ownership signal, so it denies a session removing a worktree it created itself + +> 🔢 **Filed 2026-08-04 — not started.** Value **6/10** · Difficulty **5/10** · _quick win_. Rule 3d denies on three conditions — a git token, a `worktree remove|move` match, and a target resolving under a governed root — and consults nothing about who owns the target. Ownership is instead **inferred** from a premise in the rule's own header: git refuses to remove the tree you are standing in, "so a `worktree remove` that reaches git is, by construction, aimed at somebody else's". That inference is invalid: not-the-tree-I-stand-in does not imply not-mine. + +**Cluster:** Developer tooling / session-drift controls. **Priority:** P3. **Verdict:** build. **Severity:** developer-tooling correctness with no product surface — the rule is in a PreToolUse hook, not the engine, so nothing reaches a shipped artifact and there is no PHI or security dimension. Unlike most items here it is **not conditional**: the gate is armed and its own receipt log records the false positive. + +**Measured, not inferred.** The gate's receipt log records **4 rule=3d denies, and 4 of 4 came from a session standing in its own `.claude/worktrees/` checkout** — never from the primary. Two of those slugs still own orphaned scratchpad worktrees visible in `git worktree list` today. The deny text asserts as fact that the target "belongs to ANOTHER SESSION" (`scripts/hooks/worktree_gate.ps1:535`), which in the self-created case is false. + +**The fault is the rule, not a faulty identification routine.** There is nothing to repair in identification because none is attempted: grepping the 3d block (`:508-549`) for `cwd` returns zero hits, and the file never reads `session_id` or `transcript_path` from the hook payload. The session's own cwd enters the 3d path only as a field in the receipt line. Note also that `worktree add` is **not** in the matched verb pair (`:510`), so creating the tree was never gated — only tearing it down is. + +**Why it bites rather than merely annoys.** The remediation the deny offers (`:543`, `prune-merged.ps1`) provably cannot act on that class: `prune-merged` requires a `-` sibling prefix and its own contract says it "NEVER touches ... the `.claude/worktrees` Claude-managed worktrees, the Temp scratchpad worktrees" (`scripts/worktree/prune-merged.ps1:123-124`), while `remove.ps1` addresses only `/-` (`:30`). `docs/SESSION-DRIFT-CONTROLS.md` G11 already records the underlying gap — "nested worktrees still have no scripted removal" — so for the nested and scratchpad layouts raw `git worktree remove` is the only route, and it is the route 3d denies. G11 does **not** record this false positive, so this is a new item rather than a duplicate. + +**Secondary prose error in the same block, worth fixing in the same pass.** `:537` says "Removing it deletes that session's working tree and its branch". `git worktree remove` does not delete a branch — `remove.ps1` needs a separate `git branch -D` behind `-DeleteBranch` (`:43-49`). The block also covers `move`, for which "Removing it deletes" is the wrong verb entirely. + +**Why value 6.** A confirmed, measured false positive on a control whose entire efficacy rests on its deny text being believed — and the project's own G10 entry makes that erosion the stated reason false positives matter. Held below 7 because it is Claude-process tooling rather than shipped engine code, the fallbacks (a plain terminal, or asking the owner) still work, and for the sibling layout the scripted tools remain usable, so nothing is permanently wedged. + +**Difficulty 5.** The fail-open paths for a non-worktree, nonexistent or ungoverned target already exist and must not be disturbed. The work is deciding what an ownership signal actually is — the payload carries no reliable session identity, so a fix likely means recording creation provenance at `worktree add` time and reading it at removal, which is a new mechanism rather than a new condition. + +**Related:** #1019 (the same estate's missing payload-parity instrument), #340. This file family is slated to move to a separate public repo, which is context for sequencing, not a reason to leave the rule wrong. + +**Source:** found while auditing the worktree gate, 2026-08-04, and adversarially re-verified against the code and the live receipt log at `6e481c14`. Confirmed as stated, including the "while standing elsewhere" half — standing elsewhere is what makes the command reach git at all. + +## 1020. The first-run bootstrap Administrator is created with no email address, and the PHI notification gate cannot see it + +> 🔢 **Filed 2026-08-04 — not started.** Value **5/10** · Difficulty **3/10** · _fill-in_. `_ensure_bootstrap_admin` calls `create_user` with no `email=`, so the account holding `frozenset(Permission)` has a NULL email and `SecurityEventNotifier.notify`'s `if not event.email: return` makes all ten notice types no-op for it. The PHI startup gate that refuses to serve without a notification channel computes readiness from the SMTP transport alone, so it would report a healthy channel while no notice about the all-permission account could be delivered. + +**Cluster:** Security / authentication. **Priority:** P2. **Verdict:** build (small). **Severity:** on first deployment all ten out-of-band notices about the most privileged account would silently no-op, including lockout and success-after-failures. No present-tense exposure is claimed — this is wrong in the shipped code, with zero running instances. + +⚠️ **The allocated title's second half is REFUTED and the body must say so, or a fixer will hunt for a missing unlock guard that is not missing.** The title read "and there is no administrative unlock path". Three independent paths exist, none involving email: lockout is **time-bounded** (`locked_until = now + lockout_minutes * 60`, `auth/service.py:755-759`, default 15 at `config/settings.py:1769`, enforced only while `now < locked_until` at `:659`); `POST /users/{id}/reset-password` clears `failed_attempts`/`locked_until` through `set_password` (`store/store.py:7693`); and the documented break-glass is the sealed `bootstrap-admin.txt` file (`api/app.py:5104-5146`, `docs/SECURITY.md:1750-1751`). Decisively: **there is no email-driven reset, unlock or recovery flow anywhere in the shipped code** — the notice body carries no link or token and ends "contact your MessageFoundry administrator" (`pipeline/security_notify.py:72-91`) — so the absent address removed no unlock path. + +**What is actually wrong.** `_ensure_bootstrap_admin` (`auth/service.py:522-547`) passes `user_id`, `username`, `auth_provider`, `display_name`, `password_hash`, `must_change_password` and no `email=`; every backend defaults `email: str | None = None` and inserts it (`store/store.py:7626-7644`, `postgres.py:6083-6100`, `sqlserver.py:9011-9028`). Nothing later fills it: the forced change-password flow collects passwords only (`messagefoundry_webconsole/pages/account.py:644`) and there is no self-service email route. `_notify_security` is called at 16 sites and always passes `email=user.email`, so all ten event types drop — the one that matters most being `LOGIN_AFTER_FAILURES` (`auth/service.py:725-734`), the classic "someone guessed it" signal. + +**The gate-blindness angle is the part with teeth.** On a PHI instance under `enforcement=enforce`, `serve` refuses to start without a security-notification channel (`__main__.py:2259-2280`), but `security_channel_ready` is computed purely from `notify_security_events` + `email_smtp_host` + `email_from`. At the moment that gate passes on a first run, the only account that exists has no address — so the gate proves the transport exists, not that any notice is deliverable. + +⛔ **Do not write that the notices are lost, and do not rebuild these.** The email push is dropped; every event is also an audit row surfaced by `GET /me/security-events` (`api/auth_routes.py:453`, `auth/service.py:2458`), which `auth/notifications.py:15-17` documents as the companion "so a user with no deliverable mailbox can still review their security history". Also already present: last-admin guards on disable/delete/role-removal (`api/auth_routes.py:681-688`, `:714-715`, `:746-749`), and a settable address on any account (`PATCH /users/{id}` at `:696`, console form at `messagefoundry_webconsole/pages/admin.py:171`). + +**Scope the fix wider than `admin`.** `email` is optional in `UserCreateRequest` (`api/auth_models.py:86`) and is not required for the Administrator role, so any hand-created privileged account has the same hole. A fix that hardcodes an address for the bootstrap account only would leave that open. + +**Difficulty 3, no schema change, no migration cost.** Candidate fixes are each a handful of lines plus a test: warn at `_emit_bootstrap_admin` or on the forced change-password page; extend the `__main__.py:2259` gate to require a deliverable address on at least one enabled Administrator when notifications are required; or add a self-service email field. Which one the owner wants is the only judgment needed — `auth/notifications.py:55` and `security_notify.py:128-131` explicitly contemplate "no mailbox on file", so "warn/gate for privileged accounts" may be preferable to "require an email". + +**Mentioned, deliberately not folded in:** a *sole* claimed Administrator that loses its password genuinely has no reset path, because reset needs a second `users:manage` holder and there is no `users` CLI subcommand. That is independent of email and would be unchanged by adding one. The AD/OIDC-provisioned path in `auth/reconcile.py` was not audited, so the finding may narrow to local accounts. + +**Source:** found during the ASVS V6 re-verification, 2026-08-04. The conclusion is correct and the mechanism was **misstated**: the unlock-path half is false and is corrected above, verified against the code at `6e481c14`. + +## 1022. disable_mfa has no last-factor guard where delete_webauthn_credential does, so the two removal paths can be ordered to reach zero factors + +> 🔢 **Filed 2026-08-04 — not started.** Value **5/10** · Difficulty **4/10** · _fill-in_. `delete_webauthn_credential` computes `last_second_factor` and refuses when MFA is required; `disable_mfa` has no equivalent test at all. A user with TOTP plus one passkey can delete the passkey (permitted, because `totp_enabled` is still True) and then disable TOTP (permitted, no guard), arriving at zero enrolled factors — the state ADR 0068 AC-10 says the system shall refuse. + +**Cluster:** Security / authentication policy consistency. **Priority:** P2. **Verdict:** build (small). **Severity:** low, and confined to policy consistency plus a documentation guarantee — **not an MFA bypass**. + +⚠️ **Lead with the correction: MFA enforcement at login is NOT missing and must not be touched.** The obvious reading of the title sends a fixer at enforcement that already exists. Disabling TOTP under `require_mfa` would not leave the account reachable with a single factor: `login` resolves `mfa_required` and issues the session with `mfa_verified=not mfa_required` (`auth/service.py:715-718`), and `require()` applies the second factor as an ASVS 6.3.3 **access** gate, 403ing every request whose (method, path) is not one of the six exempt pairs (`api/security.py:189-234`, `:79-88`). So the post-disable state is a **forced re-enrollment** — recoverable, because the enroll routes ride `require_reauth_only*` with `mfa_gate=False` (`api/security.py:586-589`) — not a factor downgrade and not a lockout. + +**The defect that survives.** The invariant is defeated by ordering, and it makes a shipped guarantee untrue: `docs/SECURITY.md:752` states the refusal as an unconditional property of the system, which the code does not deliver on this path. `disable_mfa` (`auth/service.py:2083-2099`) does nothing between `get_user` at `:2086` and `disable_totp` at `:2087` — it consults neither `has_webauthn_credentials` nor `_mfa_required_for`. It is the single enforcement point: both `api/auth_routes.py:438` and `messagefoundry_webconsole/routes/account.py:281` reach it unguarded. + +**This is a promised follow-up that was never filed, which is the main reason to file it rather than close it.** ADR 0068 line 140 records "TOTP-disable keeps its existing behavior this lane (parity follow-up recorded)" — and no backlog item carries that follow-up. Searches for `disable_mfa`, "TOTP-disable", "parity follow-up" and "last factor" across `docs/BACKLOG.md` and the closed archive return zero hits. So the asymmetry is a recorded decision, not an oversight. + +**Guard shape, so it is not written as a TOTP-only check.** It must consult `has_webauthn_credentials` — a user who keeps a passkey is still enrolled and must stay allowed to drop TOTP — and gate on `_mfa_required_for(user, identity.roles, second_factor_enrolled=False)`, mirroring `auth/service.py:2426-2432`. `disable_mfa` already receives an `Identity`, so `identity.roles` is available without an extra store read. + +**Difficulty 4 because the change is not local.** The guard is about six lines, but the raise must be mapped at two call sites that today have no `ValueError` handling and would 500: `api/auth_routes.py:438` (follow the confirm pattern at `:423-424`) and `messagefoundry_webconsole/routes/account.py:281` (follow `ui_webauthn_delete` at `:439-446`). One existing test **breaks by construction**: `tests/test_mfa.py:189` disables TOTP on the bootstrap admin under defaults with no passkey enrolled — its failure is the expected consequence of the fix, not a regression, and it needs a second factor enrolled first or an explicitly relaxed setting. Add a positive test mirroring `tests/test_webauthn.py::test_last_factor_delete_refused_while_required`. Two docs move in the same change: ADR 0068 line 140 becomes wrong once parity lands, and `docs/SECURITY.md:752` becomes true rather than aspirational. + +⛔ **Already handled — do not rebuild.** `admin_reset_mfa` already clears TOTP and every passkey and revokes sessions (`auth/service.py:2101-2127`), so lost-authenticator recovery is complete; and `require_step_up_action` re-checks `mfa_satisfied` (`api/security.py:638-648`) so `DELETE /me/mfa` cannot be reached by a half-authenticated session. + +**Open question for the owner:** whether to add parity, or to ratify ADR 0068's "existing behavior" as the settled end state. If the guard is added, whether it keys on `_mfa_required_for` as the passkey path does — which means a voluntarily-enrolled user under `require_mfa_scope=administrators` can still turn their own TOTP off — or on a stricter "any enrolled user keeps one factor" rule, which would reintroduce the asymmetry in the other direction. + +**Adjacent, deliberately out of scope:** `disable_mfa` does not check `user is not None` or that `auth_provider` is LOCAL, unlike `begin_mfa_enrollment` (`auth/service.py:1952-1956`), and unlike `admin_reset_mfa` it does not revoke the user's other sessions. Context only; neither is this item's claim. + +**Source:** found during the ASVS V6 re-verification, 2026-08-04. Structurally confirmed; the **consequence was overstated** and is corrected above, verified against the code at `6e481c14`. + +## 1019. install-selfheal.ps1 has no installed-vs-source payload-parity instrument, and it wires the most privileged hook in the estate + +> 🔢 **Filed 2026-08-04 — not started.** Value **5/10** · Difficulty **3/10** · _fill-in_. The installer lays down a copy of `worktree-selfheal.ps1` at `~/.claude-hooks/` and wires it as a user-scope SessionStart hook, with no way to detect that the copy and the checkout have diverged: no `-Status`, no version stamp, no hash at the `Copy-Item`, and no test that reads the installed copy. + +**Cluster:** Developer tooling / session-drift controls. **Priority:** P3. **Verdict:** build (small). **Severity:** developer-box tooling integrity, not product or PHI, and nothing here touches a deployment. + +⚠️ **The allocated title overstates in two ways, and both matter.** It read "no parity instrument at all" — the same overclaim as an earlier "no instrument of ANY kind" that had to be narrowed. **A parity instrument covering this installer does exist:** `tests/test_worktree_selfheal_wiring.py::test_both_installers_carry_the_same_refusal` compares `install-gate.ps1` and `install-selfheal.ps1` source text, its docstring noting "the asymmetry existed for months precisely because nothing compared them". That is **source-level guard parity**. What is absent is **installed-vs-source payload parity**. Second, the comparator in the claim is wrong: it named `scripts/coord/install-git-hooks.ps1`, which installs copies (`claim_check.py`, `push_guard.py`) of its own. The instrument to mirror is `install-gate.ps1 -Status` (`Get-GateHash`/`Get-GateVersion`, "parity : IN SYNC" versus "*** STALE ***") plus `tests/test_gate_installed_parity.py`. + +⚠️ **AMENDED, and it SHARPENS the item.** The verification behind this body was performed at `6e481c14`, where `install-git-hooks.ps1` had no payload parity either — so the original wording put the asymmetry against `install-gate.ps1` alone. **PR #191 then landed payload parity on `install-git-hooks.ps1`**: SHA256 content hashing, an explicit PAYLOAD-parity section, IN SYNC / STALE reporting, and `tests/test_installed_coord_hooks.py` asserting the same property from the pytest side on the same folded-comparison basis. So the correct statement is now stronger and simpler: **`install-selfheal.ps1` is the ONLY installer in the estate with no payload-parity instrument**, and two worked examples exist to copy rather than one. Nothing else here is affected — #191 did not touch `install-selfheal.ps1`, whose parameter surface is still `-ConfigDir` plus `-HookPath`, and it added no selfheal reference to either parity test. + +**Where the file is:** `scripts/worktree/install-selfheal.ps1`, **not** `scripts/coord/`. A fixer sent to `scripts/coord/` finds `install-git-hooks.ps1`, `install-coordination.ps1` and no selfheal installer. + +**What is missing, precisely, all four:** no `-Status` (the parameter surface is `-ConfigDir` plus `-HookPath`, `:23-30`); no version stamp in `worktree-selfheal.ps1`; no hash or comparison at the payload install (`:57`, a bare `Copy-Item -Force`); and no test that reads the installed copy — every test in `tests/test_worktree_selfheal_wiring.py` binds `ROOT` and uses synthetic `tmp_path` homes. The repo's only installed-vs-source parity test names the gate copy only (`tests/test_gate_installed_parity.py:44`). The installer's one detection is a regex for the **wiring's** presence in one config dir (`:85`), which says nothing about the payload the wiring points at. + +**"More privileged" is substantiated on two axes.** Scope: user-scope `settings.json` in a Claude config dir, machine-global across every repo and session, versus a repo-scoped `.git/hooks`. Action: the hook it wires **mutates a working tree unattended** — `git -C $root checkout $homeBranch` at every session start (`scripts/worktree/worktree-selfheal.ps1:106`) — whereas the claim gate and push guard only refuse. The installer's own refusal text says as much. + +⛔ **Already present — do not re-report as absent.** The `CLAUDECODE` refusal (`:39-41`, with the parameter default deliberately removed at `:24-31` so binding cannot preempt it), pinned twice; backup then write then validate JSON then roll back (`:104-118`); the wiring-presence idempotency scan (`:83-90`); and an unconditional payload refresh on every run (`:57`) — so "the installed copy can never be updated" is **not** the defect. `docs/SESSION-DRIFT-CONTROLS.md` G8's "the higher-privilege installer was the LESS PROTECTED one" describes a **closed** defect; restating it in the present tense would be false. Also out of scope: `install-git-hooks.ps1` has no `CLAUDECODE` guard at all — do not "harmonize" the pair in the direction the title implies. + +**Measured, so this is not read as an incident:** on 2026-08-04 the installed copy and the committed source agree — content hash CRLF-folded `c41c70ecf885`, 10,050 bytes, both sides. Nothing has drifted; nothing would notice if it did. + +**Difficulty 3, with four constraints a fixer would otherwise get wrong.** Fold CRLF on **bytes** exactly as `Get-GateHash` and `content_hash` do — a byte-exact hash made every Windows checkout read STALE on 2026-08-04 and prescribed a re-install that would have downgraded a machine-global file. Assert parity only when the source is committed, since mid-edit the copies are supposed to differ. Print what was scanned **before** any skip, because pytest here runs without `-rs`. Never mutate the installed copy in a test — live sessions read it — so exercise the predicate directly and include a negative control proving the folded comparison still detects a one-character change. And note that `install-selfheal.ps1` takes one mandatory `-ConfigDir` per run while `install-gate.ps1` defaults to all of them, which is why the estate doc records the backstop as present in 4 of 5 dirs. + +**Not established, and distinct from this item:** whether every Claude config dir currently carries the selfheal SessionStart entry. That is a **wiring**-coverage gap, separate from the payload-parity gap here; if both are wanted, say so rather than letting a fixer fold them together. `docs/SESSION-DRIFT-CONTROLS.md:169` scopes the parity row to the gate only, so there is no false compensating-control claim to correct — the gap is unrecorded, not misrecorded. + +**Related:** #1017 (the same estate, a different failure class). + +**Source:** found while auditing the session-drift installers, 2026-08-04, and adversarially re-verified at `6e481c14`. Confirmed with the title narrowed as above. + +## 1018. The raw-text gate-rule scan exists in three independent copies with nothing tying them together + +> 🔢 **Filed 2026-08-04 — not started.** Value **4/10** · Difficulty **3/10** · _fill-in_. Two regexes that read a gate script as text and extract every tool it dispatches on are implemented three times — twice in Python, once in PowerShell — and no test compares any two of them. They agree today; the defect is a synchronised-edit hazard whose failure direction is a false green in the machinery built to stop rules shipping dead. + +**Cluster:** Testing / developer tooling. **Priority:** P3. **Verdict:** build (small). **Severity:** no engine impact and none on first deployment; every file involved is developer session-drift tooling. + +⚠️ **There are THREE copies, not two — state the count so nobody consolidates two and closes the item.** `tests/test_install_gate_wiring.py:29-39` (`TOOL_BRANCH`/`QUOTED` plus `tools_the_gate_handles()`), `tests/test_gate_installed_parity.py:55-56` and `:110-114` (`handled_tools(text)`), and `scripts/worktree/install-gate.ps1:171-179` (`Get-HandledTools`, the same two pattern strings transcribed into PowerShell). + +**They compute the same quantity, not merely lookalikes.** Copy 2 is applied to the same file as copy 1 at `tests/test_gate_installed_parity.py:336`. All three return the identical 10 names against the source gate (Agent, Bash, Edit, EnterWorktree, MultiEdit, NotebookEdit, PowerShell, Task, Workflow, Write), and the two Python pattern strings compare equal. + +⛔ **Do not write that the copies currently disagree — they do not.** The divergence was demonstrated rather than asserted: change `"([^"]+)"` to `["\']([^"\']+)["\']` in **one** copy — the obvious one-token fix if a rule is ever written `$tool -in @('Foo')`, which PowerShell treats identically at runtime — and that copy returns a different set from the others on the same gate text (symmetric difference `['MFNewRule']`). Word it as a synchronised-edit hazard. + +**Name the failure direction, because it is what gives the item its value.** If copy 2 under-matches, `required = handled - OPT_IN_TOOLS` (`tests/test_gate_installed_parity.py:320`) shrinks and `test_every_non_optional_rule_is_wired_in_every_config_dir` **passes having checked less**. If copy 3 under-matches, `-Status` prints no UNWIRED line and reads as a clean audit. Both are false greens in files written precisely because a rule once shipped dead while 85 tests stayed green. + +**A shared Python helper cannot absorb the third copy.** `install-gate.ps1` is PowerShell. The honest end state is one helper (`tests/_gate_rule_scan.py`, following the existing `tests/_workflow_contexts.py` convention) **plus** a test asserting `Get-HandledTools` agrees with it on `scripts/hooks/worktree_gate.ps1`. Without that second half the item is half done and reads as done. + +⛔ **Do not add a pin that exists.** `tests/test_install_gate_wiring.py:78-91` already pins copy 1's output to a 10-name literal, and `tests/test_gate_installed_parity.py:333-338` already asserts copy 2 sees `EnterWorktree`. Say what neither does: the pin guards **regression**, not a new rule in an unmatched form — appending `if ($tool -in @('MFNewRule'))` to the gate leaves the assertion passing — and nothing anywhere compares any two of the three copies. + +**Fix the second triplication in the same pass:** the opt-in exemption is stated three times too, at `tests/test_gate_installed_parity.py:53`, `tests/test_install_gate_wiring.py:139` and `scripts/worktree/install-gate.ps1:243`, also with no cross-check. + +**The coupling this item is really about** is visible in the gate's own source: `scripts/hooks/worktree_gate.ps1:272` says the rule was "Expressed as `$tool -in @(\"EnterWorktree\")` so tests/test_install_gate_wiring.py SEES this tool as handled". The gate was contorted into the one syntax the scan recognises. + +**Difficulty 3.** The Python half is a mechanical move plus two call-site changes. The remaining cost is the cross-language pin — a `pwsh` subprocess with a skip where `pwsh` is absent, which by this project's standard needs the announce-before-skip treatment `tests/test_gate_installed_parity.py` already uses — plus deciding whether the helper also owns `OPT_IN_TOOLS`. + +**Open, and possibly its own item:** the scan misses `$tool -eq "X"`, `switch ($tool)`, `$tool -in $SomeVariable` and single-quoted names, all valid PowerShell that would run and be invisible to all three copies. Consolidating gives that blind spot one address to fix; it does not fix it. + +**Related:** #1017, #1019 (the same estate). + +**Source:** found while auditing the gate wiring tests, 2026-08-04, and adversarially re-verified at `6e481c14`. Confirmed, with the copy count corrected from two to three. **Anchors into `tests/test_gate_installed_parity.py` were re-derived BY CONTENT at `17374679`** after PR #191 edited that file and displaced four of them (`handled_tools` `:105` to `:110`, the `required` line `:277` to `:320`, the source-gate call `:293` to `:336`, the opt-in test `:290` to `:333`). diff --git a/docs/archive/backlog/BACKLOG-CLOSED.md b/docs/archive/backlog/BACKLOG-CLOSED.md index f31f08b1..1fcd8fa9 100644 --- a/docs/archive/backlog/BACKLOG-CLOSED.md +++ b/docs/archive/backlog/BACKLOG-CLOSED.md @@ -5485,3 +5485,136 @@ Nothing caught it because `ide/src/test/suite/ai-policy.test.ts:31-40` tests the **Source:** adversarial review of the ADR 0087 sandbox codec, 2026-08-01; confirmed by direct execution of `_partition`. --- + +## 322. Synthetic leak-gate placeholders can collide with the real gate's own guards + +> ✅ **BUILT 2026-08-04 — the guidance half only; the “optionally…” half is deliberately NOT built.** Value **2/10** · Difficulty **1/10** · _fill-in_. **Shipped:** a `PLACEHOLDERS IN TRACKED CONTENT` block in the header region of `scan-tokens.local.txt.example`, mirrored in `CONTRIBUTING.md` and `scripts/dev/setup-leak-gate.ps1` — the script now prints the stand-in warning on **both** installs, not just `-Synthetic`, because with the real list a placeholder built from a listed prefix is an actual disclosure rather than a false positive. Three guards in `tests/test_scan_tokens_source.py`, each falsified against a planted defect: the two pure-prose files must be COMPLETELY clean under the synthetic set (not merely site-code-free — naming the synthetic placeholders outright to explain them would block a fork contributor on the very files that document the gate); the `.example` free of both site-code classes, behind a precondition that the detectors are ARMED so the emptiness cannot pass vacuously through the `_NEVER` sentinel; and the header's stated counts must equal what it compiles to **with nothing dropped by the parser** — counts alone are blind to a line that fails to compile and is discarded with only a stderr warning, verified by removing that half and watching an unbalanced-paren line inserted into `[names]` pass every test in the tree. **Three facts in the body below are wrong; the shipped text corrects them rather than copying them.** (1) “a site-code hit, twice” is two OCCURRENCES of ONE detector — `_SITE_CODE_FILE` requires four LITERAL digits and `_SITE_CODE_PATTERN_LITERAL` a quantifier or an x-run, so no single string is both; measured, a placeholder value gives exactly one `site code` hit. The useful consequence is the inverse of the one implied: fixing one form does **not** clear the other, which is the ADR 0030 miss the scanner already records. (2) `` is safe because it is **non-numeric**, not because `_HOME_PATH`'s `(?!<` lookahead exempts it — that lookahead is positional to a `/Users/`-style segment and never reaches the site-code detectors; measured, an angle-bracketed code and an angle-bracketed x-run both fire. (3) Nothing scans a commit message — the hook passes staged FILES and CI runs `--path .` — so the guidance says the opposite: there is no net behind you in a subject line. **NOT built, by lane ruling:** the per-hit reason string naming the loaded set. It would collide with a later wave on `scan_forbidden.py`, which this lane leaves untouched, and the three-state load banner the scanner already prints before any refusal covers the diagnostic need. **Corrected in passing:** `CONTRIBUTING.md` told a synthetic-set contributor their “commits will pass” — measured 2026-08-04 that set produces 649 hits across 120 tracked files, because its placeholders are the fictional customer/partner names the project's own docs and samples use throughout; the bullet now says it is a DIFFERENT detector set and to judge a hit against the run banner. _(was 4/10 · 2/10; the re-score rationale is unchanged and stands in the ranked-table row above.)_ + +**Cluster:** Security / DX. **Priority:** P3. **Verdict:** build (small). **Severity:** low. + +> **This item deliberately does not spell out the offending value.** Writing the prefix, or the prefix followed by four digits, would trip the very detector described — `[site_prefix]` builds *two* patterns from its entry: the prefix plus four digits, **and** the pattern written out as an x-run or quantifier. That recursion is the whole point of the item, so it is demonstrated rather than described. + +**What:** while redacting the #321 tokens, the first replacement chosen followed `scripts/security/scan-tokens.local.txt.example`'s own stated convention — that file designates a specific non-real numeric prefix for synthetic use, and the replacement was built from it. Under the **real** token set that value is clean. Under the **synthetic** set it is a site-code hit, twice, and the scan exits 1: + +``` +MEFOR_FORBIDDEN_TOKENS=scripts/security/scan-tokens.local.txt.example \ + python scripts/security/scan_forbidden.py --path $T +# -> docs/BACKLOG.md:: site code (x2), exit 1 +``` + +`scripts/dev/setup-leak-gate.ps1 -Synthetic` is a **documented, supported contributor setup** (the example file calls it so in its own header), and the pre-commit hook passes `--require-tokens`, so it blocks *every* commit — not just ones touching that file. A contributor with no access to the real token list would hit an unexplained hard block on unrelated work. The final commit uses the non-numeric `SITEA` instead, which cannot collide with any numeric detector. + +**Why:** the example file's synthetic-prefix guidance is written for the person filling in the **token list**, where it is correct and necessary. But it reads as general guidance for *placeholder values*, and a placeholder written into tracked prose is then scanned by the gate that list configures. The convention is self-colliding for its second audience, and nothing warns you. The same trap caught [#325](BACKLOG.md), whose worked examples had to be rewritten to the exempt `` form for exactly this reason. + +**Proposed:** state in `scan-tokens.local.txt.example` (and in the redaction guidance) that a placeholder written **into tracked content** must not use any prefix appearing in `[site_prefix]` in *either* the real or the example set — prefer a non-numeric stand-in (`SITEA`, ``), matching the `<…>` convention `_HOME_PATH` already exempts. Optionally have the scanner's hit message name the loaded set, so a synthetic-set false positive is self-diagnosing rather than reading as a real leak. + +**Related:** `scripts/security/scan-tokens.local.txt.example`, `scripts/dev/setup-leak-gate.ps1`, `.pre-commit-config.yaml` (the `--require-tokens` arm), #321. + +**Source:** public-repo disclosure audit, 2026-08-01; found by testing the redaction under both token sets before committing `f3c6d348`. + + +--- + +--- + +## 334. semgrep, a required blocking gate, scans a two-directory allow-list + +> ✅ **Status CLOSED (built 2026-08-04).** `semgrep --config .semgrep --error --metrics off messagefoundry tee` is now `semgrep --config .semgrep --error --metrics off --exclude … .` carrying bandit's exclude set from the same file, name-for-name, so the project's own dangerous-sink rules now cover **59** tracked `.py` files they never saw: `messagefoundry_webconsole/` (33), `scripts/` (24), `docker/` (2). **The body's "56 / 32 / 22" is a stale measurement, not a different scope** — re-measured 2026-08-04 at **339** in-scope files, up from 280. Clean at that bar (0 findings, AST emulation of all five rules); **not run with real semgrep, which has no supported Windows install** — the first CI run on the PR is the real check. `tests/test_lint_scope_parity.py` carries the parity arm the item asked for, plus three assertions it did not: no `--include` (it re-narrows the scan behind a positional `.`, so a targets-only check reads green on this very regression), no `./` prefix on a semgrep `--exclude` (a glob, not a path — and the set comparison normalises `./` off both sides), and `--error` still present (without it the widened gate prints every finding and exits 0). **Every `security.yml:NNN` anchor in the body below has moved** — the command is now at `:449`; the job still starts at `:393`. Two claims elsewhere rested on this item's old state and were corrected in the same commit: ADR 0034's residual row mitigated an unpinned `pip` bootstrap with *"semgrep is **not** a required context"* (it is — `.github/required-contexts.txt:78`), and `docs/Secure_Build_Scorecard_MEFOR.md:56` carried a now-resolved nit about the `.semgrep` header still calling the rules "advisory". + +**Cluster:** Security / CI gates. **Priority:** P2. **Verdict:** build. **Severity:** low. + +**What:** `.github/workflows/security.yml:413` runs the project SAST gate as an allow-list: + +``` +semgrep --config .semgrep --error --metrics off messagefoundry tee +``` + +It is a **required merge context** (`.github/required-contexts.txt:78`) and **blocking** (`security.yml:396`). Its sibling on the same file was moved off that shape deliberately — `security.yml:359-360` is `bandit -r .` minus an explicit `--exclude` list, and the rationale at `:348-352` names the failure by name: *"It was `-r messagefoundry tee` while the hook scanned everything except tests/harness/samples — so scripts/ (security tooling, subprocess-heavy) was gated locally and by nothing in CI."* semgrep was never given the same treatment. + +Taking bandit's exclude set as the project's own declaration of what SAST is supposed to cover, the delta is **56 tracked `.py` files**: `messagefoundry_webconsole/` (32 files, which ships as its own separately-versioned wheel — `packaging/messagefoundry-webconsole/pyproject.toml:17`, force-included at `:55-58`), `scripts/` (22 — the security tooling the bandit comment was written about), and `docker/` (2). `packaging/`'s 14 files are all tests and are excluded on both sides. + +`tests/test_lint_scope_parity.py` is cited at `security.yml:358` as the control that stops this ("fails if this and the hook drift apart again"), and it does hold that line for ruff and bandit — `test_ci_bandit_scans_the_repo_not_an_allow_list` (`:119-125`) asserts `bandit\s+-r\s+\.` against the workflow directly. **The string "semgrep" does not appear anywhere in that file.** There is no semgrep pre-commit hook either, so semgrep is CI-only and that CI-only assertion at `:119` is the exact template a semgrep arm would follow. + +**Why:** the honest blast radius is **drift, not exposure** — three things bound it, and the item is worth filing anyway: + +1. **Nothing is being missed today.** Grepping `messagefoundry_webconsole/` for every sink the five rules match (`shell=True`, `os.system`, `eval(`, `exec(`, `pickle.`, `marshal.`, `yaml.load(`, `verify=False`) returns **zero** hits. The console makes no outbound HTTP calls of its own — `routes/oidc.py` delegates code redemption to `messagefoundry.auth.oidc`, which *is* scanned. +2. **bandit is a real compensating control on the same PR.** It is also required (`required-contexts.txt:74`), it does scan all 56 files, and its built-in checks for `exec`/`eval`, pickle/marshal, `yaml.load` and `shell=True` are **not** in the `--skip` list at `:359` (that list is only `B101,B110,B311,B404,B608`). So four of the five rules have overlapping enforcement. *(Confirm the specific bandit check-id mapping before leaning on this in review; the skip list is what was read, not bandit's plugin source.)* +3. **CodeQL also covers it** — `codeql.yml:55` analyses python repo-wide with `security-extended` (`:66`) and has no paths filter. But it is **deliberately not a required context** (`required-contexts.txt:108-110`: fork-PR tokens lack `security-events: write`), so it is a detector, not a gate. + +What is *not* covered is the thing that will grow: `.semgrep/messagefoundry.yml` is where **project-specific** rules land — rules bandit will never ship. A future rule written because of something an operator hit in the console would silently not run on the console. The second step in the same job (`security.yml:414-424`, the ADR 0144 handler-taint rules) is scoped to `samples/config` only, so it does not close this either. + +**Correcting the audit that produced this item:** the source finding claimed the five rules "cover exactly the sinks that matter in an HTML-rendering console." They do not — `.semgrep/messagefoundry.yml:5-53` contains no HTML, template, escaping or XSS rule at all. The rules are generic dangerous-sink rules (`:6`, `:14`, `:21`, `:30`, `:43`). The finding also named only the web console; `scripts/` is out of scope on the same line and is the directory the bandit widening was specifically about. + +**Proposed:** + +1. Change `security.yml:413` to scan the tree the way bandit does — `semgrep --config .semgrep --error --metrics off --exclude … .` — mirroring bandit's `--exclude` set exactly so the two gates cannot disagree about what "the project" is. **Verify the tree is clean at that bar in the same PR**: `--error` is blocking, and `tests/` (563 files) very plausibly contains `pickle`/`yaml.load`/`eval` test idioms, which is presumably why `tests`/`harness`/`samples` are excluded on the bandit side too. +2. Add `test_ci_semgrep_scans_the_repo_not_an_allow_list` to `tests/test_lint_scope_parity.py`, modelled on `:119-125`, so the next narrowing has to be deliberate. This is the durable half — without it, step 1 can rot again exactly as bandit's did. +3. If a full widening is rejected, the fallback is appending `messagefoundry_webconsole scripts docker` to the argument list — but note that this is the allow-list shape `:348-352` explicitly retired, and it will go stale the next time a directory is added. + +**Related:** `.github/workflows/security.yml:348-360` (the bandit precedent) and `:393-424` (the semgrep job), `.semgrep/messagefoundry.yml`, `tests/test_lint_scope_parity.py`, `.github/required-contexts.txt:74`/`:78`/`:108-110`, `.github/workflows/codeql.yml`, `packaging/messagefoundry-webconsole/pyproject.toml`, [ADR 0065](adr/0065-web-ops-dashboard.md) (the console is a distinct distribution), [ADR 0144](adr/0144-handler-config-taint-rules.md) Inc 3 (the second, `samples/config`-scoped semgrep step). + +**Source:** public-repo disclosure audit, 2026-08-01. + +--- + +--- + +--- + +## 336. Dependabot auto-merge shields review with a deny-list + +> ✅ **SHIPPED 2026-08-04 — guardrail #3 inverted from a 16-name deny-list to an ecosystem-qualified ALLOW-SET (hold unless named); a fail-closed release-age gate added as #4.** Value **5/10** · Difficulty **3/10** · _fill-in_. §1 `.github/workflows/dependabot-auto-merge.yml` now holds any PR whose dependencies are not on their own ecosystem's allow row — `actions/`/`github/`/`dependabot/` for `github-actions`, deliberately EMPTY for `uv` and `npm`, and an unrecognised ecosystem token holds rather than merges — preserving the fail-safe whole-group denial (measured: PR #75's five-bump batch carried `pypa/gh-action-pypi-publish`, so that batch would now HOLD). §4 a new `id: age` step requires every SECURITY-track candidate version to have been published at least 24h, failing closed on an API error, an absent/unparseable upload timestamp, an unexpected name or version shape, or an ecosystem with no publish-date source wired. ⚠️ **#4 is a FORWARD guard and is INERT with respect to the merge decision as shipped** — `age_ok=true` is reachable only for `uv`/`pip`, `eligible=true` only for `github-actions`, and the merge `if` requires both, so the two sets are disjoint. It is recorded that way in the workflow header rather than as an operating control, and gated on the allow-set so it makes no unauthenticated outbound request from the `contents: write` job for a PR that holds regardless; it becomes load-bearing the day a Python allow row is populated (an owner decision) or the advisory gate is made ecosystem-aware. §3 `tests/test_dependabot_automerge_guardrails.py` now asserts a cooldown on EVERY configured ecosystem behind a vacuity floor, and executes the shipped `run:` bodies under `bash -e` — the shell GitHub Actions actually applies — rather than only reading the YAML. §5 the false-premise backstop clause is corrected rather than merely deleted: no REQUIRED check reads a dependency's shipped bytes, and `trivy`, which does read the built image's bytes, is advisory (`continue-on-error`) and cron/dispatch-only, so it never runs on a Dependabot PR at all. **§2 was ALREADY SHIPPED** by the 2026-08-03 amendment (`.github/dependabot.yml` sets `cooldown.default-days: 5` on `github-actions`) and was NOT rebuilt; **§6 is discharged by DELETING the deny-list** rather than pruning it, which removes `python-jose`/`pyjwt`/`passlib` — all three absent from `requirements.lock`'s 98 pinned distributions — along with it. + +> ⚠️ **AMENDED 2026-08-03 — the `github-actions` cooldown SHIPPED, discharging Proposed §2 in substance and half the false-premise finding with it.** The second measured-at-HEAD bullet asserts that ecosystem *"carries `schedule` + `groups` only; there is no `cooldown:` key"*, but `.github/dependabot.yml:83-84` now sets `default-days: 5` for it, with the rationale at `:75-82` (#75 took two of five bumps to `main` under 24h from publish; `codeql-action` v4.37.4 was 7h old). So the *"Bounding this honestly"* line **"Only `github-actions` is unaged"** no longer holds, and each of the three configured ecosystems now has a cooldown behind the header's claim (now at `.github/workflows/dependabot-auto-merge.yml:22-24`, not `:16-18`). ⚠️ **Read §2 as discharged in substance, not to the letter** — `.github/dependabot.yml:79-80` records that this ecosystem honors `default-days` alone and ages off the **tag's commit date**, "so treat 5 as approximate", which is why §2's *"matching uv's 5/7"* could not be met. +> +> **The deny-list itself is untouched, so the rest of the item stands:** 16 Python names at `:84-85` gating every ecosystem behind an author-only job condition (`:64`) with no ecosystem qualifier, a merge gate still keying on `version-update:semver-patch` (`:184`), `tests/test_dependabot_automerge_guardrails.py:107-108` still asserting a cooldown for `uv` alone, and `python-jose` / `pyjwt` / `passlib` still absent from `requirements.lock` — so §§1, 3, 4, 5 and 6 are unaffected, as is the Why's other leg (`.github/workflows/security.yml:261-262` still describes pip-audit as *lockfile only* and bandit/semgrep as *source only*). ⚠️ **At least four `dependabot-auto-merge.yml` citations above (`:16-18`, `:58`, `:78-79`, `:155-161`) and all four `dependabot.yml` ones now point at different lines** — re-measure before quoting one; the `tests/` and `security.yml` citations are still exact. + +> **AMENDED 2026-08-04 — one clause of the 2026-08-03 note above is superseded by the SHIPPED banner; the dated measurement itself stands and is deliberately left as written.** *"The deny-list itself is untouched, so the rest of the item stands … §§1, 3, 4, 5 and 6 are unaffected"* was accurate when measured. It is not now: the deny-list no longer exists — guardrail #3 is an allow-set — so §6 is discharged by deletion rather than annotation, and §§1, 3, 4 and 5 are built rather than merely unaffected. The 16 names survive only as a PROPERTY under test (`_DENY_PACKAGES` in `tests/test_dependabot_automerge_guardrails.py` asserts none of them reaches any allow row), not as a mechanism. + + +**Cluster:** Security / Supply chain. **Priority:** P3. **Verdict:** build. **Severity:** low. + +**What:** `.github/workflows/dependabot-auto-merge.yml` decides unattended merges by exclusion. Guardrail #3 is a hard-coded shield list at `.github/workflows/dependabot-auto-merge.yml:78-79`: + +``` +denylist="cryptography argon2-cffi argon2-cffi-bindings paramiko ldap3 pyspnego \ + fastapi starlette uvicorn pydantic pydantic-core python-jose pyjwt passlib bcrypt cffi" +``` + +Anything not on that list, on any ecosystem, auto-merges if it is a patch — the merge gate at `:155-161` keys only on `update-type == 'version-update:semver-patch'` (plus dev-only minors), with no ecosystem or dependency-type qualifier. The workflow itself already names the residual at `:31-34`: "a malicious patch that BOTH rides a real concurrent published advisory AND is not on the deny-list would still auto-merge." + +Two things measured at HEAD make the exposed set **wider than that comment implies**: + +1. **All 16 names are Python distributions, but the job has no ecosystem filter.** The only gate is `if: github.event.pull_request.user.login == 'dependabot[bot]'` (`:58`), so the npm (`/ide`) and `github-actions` ecosystems configured in `.github/dependabot.yml` run this same path with **zero** deny-list coverage — no npm or action name can ever match a Python token. +2. **The `github-actions` ecosystem has no cooldown.** `.github/dependabot.yml:42-56` carries `schedule` + `groups` only; there is no `cooldown:` key, unlike uv (`default-days: 5`, `:26-30`) and npm (`default-days: 3`, `:66-68`). That falsifies the compensating-control claim in the workflow header at `.github/workflows/dependabot-auto-merge.yml:16-18` — "Fresh-release supply-chain poisoning is handled upstream by the dependabot.yml `cooldown`" — for the one ecosystem whose artifacts execute inside CI. Actions are SHA-pinned (e.g. `actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1`, `.github/workflows/release.yml:73`), and a Dependabot patch bump rewrites that SHA with no aging and no shield. + +Two smaller corrections to the record. The deny-list is 16 names but only **13** are real dependencies: `python-jose`, `pyjwt` and `passlib` do not appear in `requirements.lock` (98 pinned distributions), so the shield covers 13 of 98. And `tests/test_dependabot_automerge_guardrails.py:97-108` asserts a cooldown only for `package-ecosystem == "uv"` — nothing tests that the other two ecosystems have one, which is why the gap is invisible to CI. + +**Why:** this is the "compensating control resting on a false premise" rule (CLAUDE.md §11) firing on a supply-chain gate. The header's stated backstop is weaker than it reads in a second way too: `.github/workflows/security.yml:261` says in the repo's own words that "pip-audit (lockfile only) and bandit/semgrep (source only)" never see a dependency's contents — semgrep targets `messagefoundry tee` (`security.yml:413`) — and the required contexts `pip-audit (dependency vulnerabilities)` / `npm-audit (ide dependency vulnerabilities)` (`.github/required-contexts.txt`) are known-advisory scanners, which by construction cannot flag a package that was malicious at publish time. So "main's required CI backstops both" (`.yml:33-34`) does not hold against the specific threat the deny-list exists to cover. + +**Bounding this honestly — the blast radius is NOT what it first looks like:** + +- **The attacker must already own an upstream publisher account.** That is a capability with worse uses than this repo; nothing here is a privilege escalation for a lesser attacker. +- **On the security track the GHSA gate is real and fails closed** (`:136-145`): an API error or a no-matching-advisory result routes to manual review. Because it queries `ecosystem=pip` (`:132-135`), npm and actions security PRs can never match and always fall to manual — a false negative in the *safe* direction. +- **On the version track the uv and npm ecosystems are cooldown-aged** (5 and 3 days). Only `github-actions` is unaged. +- **Merging to main is not publication.** `.github/workflows/release.yml:30-41` gates PyPI on a `vX.Y.Z` tag push, and `workflow_dispatch` explicitly does not publish (`release.yml:5-8`), so reaching a downstream install still requires the owner to cut a tag. The sharpest theoretical path — a poisoned action riding in `release.yml`, whose job holds `id-token: write` for Trusted Publishing (`release.yml:70`) — needs that same owner tag push to execute at all. +- **This is hardening, not an incident.** No evidence of exploitation; the item is that the control's stated premise and its configuration disagree. + +**Proposed:** + +1. **Invert guardrail #3 to an allow-set.** Replace the 16-name deny-list at `:78-79` with an explicit list of packages eligible for unattended patch merge; everything else routes to manual review. The current default is "merge unless named"; it should be "hold unless named." Keep the fail-safe whole-group denial semantics (`:81-92`). +2. **Add a `cooldown:` to the `github-actions` ecosystem** in `.github/dependabot.yml:42-56`, matching uv's 5/7. This is the one-line change that makes the header's claim at `:16-18` true instead of aspirational. +3. **Extend `tests/test_dependabot_automerge_guardrails.py:97-108` to assert a cooldown on every ecosystem**, not just `uv` — the test's narrowness is why item 2 was invisible. +4. **Security-track aging must be workflow-side, not dependabot-side.** "Extend the cooldown to the security track" is *not* implementable in `.github/dependabot.yml`: that file records at `:22-25` that security updates ignore cooldown by Dependabot design. The equivalent is a release-age check in the workflow — require the candidate version to have been published for N hours before auto-merging — placed alongside the GHSA step and failing closed the same way. +5. **Correct the header comment.** `:33-34` should stop citing pip-audit/bandit/semgrep as a backstop against a fresh malicious publish, since `security.yml:261` already states they cannot see it. Either drop the clause or scope it to "known-CVE regressions." +6. **Prune or annotate the three non-dependency deny-list entries** (`python-jose`, `pyjwt`, `passlib` are absent from `requirements.lock`) so the list's apparent breadth matches its effective breadth. If they are deliberately prophylactic, say so in the comment. + +**Related:** `.github/workflows/dependabot-auto-merge.yml`, `.github/dependabot.yml`, `.github/workflows/security.yml`, `.github/workflows/release.yml`, `.github/required-contexts.txt`, `requirements.lock`, `tests/test_dependabot_automerge_guardrails.py`, `.github/workflows/dependabot-lock-resync.yml`; #321, #322, and the unhashed release-toolchain item from this same audit (a different file and a different fix). + +**Source:** public-repo disclosure audit, 2026-08-01. + +--- + +--- + +---