diff --git a/docs/releases/SCHEDULABLE-BACKLOG-MULTISESSION-PLAN.md b/docs/releases/SCHEDULABLE-BACKLOG-MULTISESSION-PLAN.md new file mode 100644 index 00000000..ebd99f77 --- /dev/null +++ b/docs/releases/SCHEDULABLE-BACKLOG-MULTISESSION-PLAN.md @@ -0,0 +1,316 @@ +# MessageFoundry — Multisession Execution Plan: the schedulable backlog (2026-08-03) + +**Clearing the 41 schedulable items from the 2026-08-03 re-score into 37 parallel-safe sessions across 5 waves, grouped so that no two sessions in the same wave ever co-own a file** (two deliberate exceptions — `docs/BACKLOG.md` **banner regions**, line-disjoint per banner; and the four-file share between W5's `private-paths-ignore-control` and `merge-gate-negative-controls`, line-disjoint and merge-ordered — both mitigated in §C). Method: **coordinator + one worker per session in its own worktree (`scripts/worktree/new.ps1 -Name plan-`) branched off `origin/main` @ `1cf04732`; workers build + verify + local-commit; the owner opens and approves every PR.** Every item below was file-mapped against the code at HEAD (`857a70d4`) with Glob/Grep/Read — the map corrections, missed seams and phantom references found during that pass are recorded in §D, because a *missed* shared file is the failure this plan exists to prevent. Status: **AUTHORED (2026-08-03), adversarially reviewed and revised (2026-08-03) — ready to dispatch.** What the review found and what moved is recorded in *Adversarial review* at the end; the two `docs/BACKLOG.md` surfaces it exposed are now owner-only (§C, §D RULE 1). + +> **The 52 DEMAND-GATE items are not here — by the rule, not by oversight.** They are excluded because the project refuses to build speculative connectors, codecs and parity knobs before a real feed/adopter/deployment fires their trigger, and because the demand-gate triage protocol requires a pause-explain-ask before any of them is scheduled. They are planned separately in [`DEMAND-GATE-BACKLOG-MULTISESSION-PLAN.md`](DEMAND-GATE-BACKLOG-MULTISESSION-PLAN.md); their banners and triggers live in `docs/BACKLOG.md`. **Do not pull one into a wave here.** + +--- + +## Start here — immediately actionable + +The five Wave-1 sessions with **no upstream code dependency**. P1s lead. + +| Item(s) | Session (wave) | Gate | What | +|---|---|---|---| +| **#341** (P1, 9/10) | `dryrun-fanout-contract` (W1) | **owner fork** — widen the container vs raise `ValueError`; the two give *different dispositions* (delivered vs `ERROR`/dead-letter) | A Handler returning a **tuple or set** of `Send`s delivers nothing and errors nothing — `_partition` (`pipeline/dryrun.py:102`, narrowing at `:112`) makes the container the single item, all three `isinstance` filters miss, message finalizes `FILTERED`. An accept-and-drop of PHI, the one thing CLAUDE.md §12 forbids outright. | +| **#326** (P1) + #328 | `cli-exposure-and-audit-anchor` (W1) | **owner fork** on #326 §3 (refuse-on-upgrade vs warn-first) | Re-key `admin_exposed` (`__main__.py:1881`) off the `serve_ui`-independent `instance_exposed` predicate already at `:2368` — the MFA-at-exposure refusal currently reads `serve_ui` *after* it is flipped off; plus `audit-anchor` + `--expected-anchor` so startup auto-verify stops being truncation-blind. | +| **#325** + **#321** (P1) | `leakgate-detector-blindspots` (W1) | code half starts today; **#321's token data is owner-run** (private token file **and** both the Actions *and* Dependabot secret stores) | Case-fold the drive-letter arm of `_HOME_PATH` (`scan_forbidden.py:99-106`) + the `_WORKTREE_SLUG` sibling at `:92`; add the prefix-free structural backstop for the estate-identifier shape. **Measure against the whole tracked tree first** — whole-pattern `re.I` was measured at 47 false positives. **This session is the sole owner of `scan_forbidden.py` in W1 and carries any `nosemgrep` annotation #334's widened scan needs in that file.** | +| **#347** | `phi-at-rest-assertion-strength` (W1) | none | Replace three sub-6-character substring-absence assertions against random ciphertext with the deterministic `assert ADT not in raw` idiom already at `test_store_encryption.py:56`. Cleanest item in the batch — two files, both verified line-exact. | +| **#334** | `semgrep-scope-widening` (W1) | none, but the widened scan **must be clean in the same PR** (`--error` is blocking) | semgrep — a required blocking gate — scans a two-directory allow-list (`security.yml:413`). Widen to repo-wide carrying bandit's exact `--exclude` set (`security.yml:359-360`) and add the parity test to `tests/test_lint_scope_parity.py` (the string "semgrep" appears nowhere in it today). **`scripts/security/scan_forbidden.py` is OFF-LIMITS to this session** — see §C. | + +> **Two P1s are deliberately NOT here.** **#324** (custom role with `messages:edit` reads raw PHI via `/ui`) sits in **W2** only because `docs/SECURITY.md` is W1-owned by `cli-exposure-and-audit-anchor`; see §C for the swap the owner can elect. **#1000** (prove each required merge context can fail) sits in **W5** because it co-owns files with *five* other CI items — and it carries a scope warning: **3 days is a cap, not an estimate.** Splitting it by gate family and pulling one slice forward is a good owner call; the adversarial review left that recommendation standing and un-merged #327 from it so the split stays cheap. + +--- + +## A. Session roster + +| Wave | Session | Items | Effort | Owns (files / seams) | Notes | +|---|---|---|---|---|---| +| 1 | `dryrun-fanout-contract` | #341 | 1 | `pipeline/dryrun.py`, `pipeline/dryrun_trace.py`, `pipeline/_sandbox_codec.py`, `config/wiring.py`, `lens.py` (comment review), `tests/test_dryrun.py`, `tests/test_sandbox_codec.py`, `docs/adr/0087-*.md` (amendment note), `docs/CONNECTIONS.md`, `docs/USER-GUIDE.md` | **`tests/test_sandbox_codec.py::test_partition_parity_table` (:580-602) pins `tuple_of_sends`/`set_of_sends` as `[0,0,0]` in BOTH modes and argues in its docstring that normalising them "would start delivering Sends the current engine does not (shipping PHI it drops today)". That test and its rationale must be REWRITTEN, not extended.** `CONNECTIONS.md:205` + `USER-GUIDE.md:395` state the "return a list" contract to users — the prose moves with whichever fork wins. **Anchor VERIFIED, not drifted: `_partition` is defined at `:102` and the narrowing `items = result if isinstance(result, list) else [result]` is at `:112` — the item's own citation is correct. An earlier draft of this plan "corrected" it to `:110` (`if result is None:`; `:109` closes the docstring); that correction was false and is struck.** | +| 1 | `cli-exposure-and-audit-anchor` | #326 + #328 | 2 | `messagefoundry/__main__.py`, `config/settings.py` (IntegritySettings only), `pipeline/engine.py`, `tests/test_cli.py`, `tests/test_checks_gate_parity.py`, `tests/test_audit_integrity.py`, `docs/CONFIGURATION.md`, `docs/SECURITY.md`, `docs/SECURITY-LOOSENING.md`, `docs/REMOTE-CONSOLE.md`, `docs/FEATURE-MAP.md`, `docs/EARLY-ADOPTER-GUIDE.md` | **Combined because both edit `__main__.py` + `test_cli.py` + `CONFIGURATION.md`.** #326: `checks.py` is NOT a second gate site (no `require_mfa`/`is_loopback` reference) — extend `test_checks_gate_parity.py`, which drives the real gate via `main(['serve',...])`. **Keep the predicate hoist INSIDE `__main__.py`** — hoisting it into `settings.py` collides with three other items. #328: **no store migration** (`audit_anchor()` already on `store/base.py:1491` + all three backends); **DROP `api/app.py` from scope** (§1-3 are CLI + settings) to relieve contention. `FEATURE-MAP.md:229` ("CLI — 30 subcommands") is hand-maintained prose — no test catches it if missed. | +| 1 | `steps-dropmodel-and-save-refresh` | #233 + #234 | 3 | `ide/src/stepsModel.ts`, `ide/src/stepsView.ts`, `ide/media/stepsWebview.js`, `ide/src/test/suite/steps.test.ts`, `ide/src/test/suite/steps-edit.test.ts`, `ide/esbuild.js` + `ide/package.json` + `ide/tsconfig.json` (option (a) only), `docs/adr/0076-*.md` | **Combined: both co-own `stepsModel.ts` + `stepsView.ts` + both steps test suites.** #233: owner picks (a) shared bundled module / (b) host-computed preview / **(c) differential test only — cheapest, touches only `stepsModel.ts` + `steps-edit.test.ts` and drags in NO packaging files**. Only `stepsWebview.js`, `stepsModel.ts` and `steps-edit.test.ts` name `blockExtent`/`walkMove`/`resolveDrop`. `steps/index.ts:13` globs `**/*.test.js` — a new test file needs no registration. #234: `EditLoopGuard` is constructed in `stepsView.ts:278` but **exported from `stepsModel.ts`** — the race fix lands there. **`RERENDER_DEBOUNCE_MS=250` at `stepsView.ts:89`, fired at `:855` inside the save subscription at `:839`.** ADR 0076 §5's "sync on save only" guardrail must be re-argued in an amendment before it is relaxed. **#233 must merge before W4's #237.** | +| 1 | `semgrep-scope-widening` | #334 | 1 | `.github/workflows/security.yml`, `tests/test_lint_scope_parity.py`, `.semgrep/messagefoundry.yml` | `required-contexts.txt` is **not** edited — the context name does not change, only its argv. No doc edit needed: `CI.md:20/:46` and `Code_Quality_Standards.md:287` name semgrep but state no scope. The +56 newly-scanned files (`messagefoundry_webconsole/` 32, `scripts/` 22, `docker/` 2) may need `nosemgrep` annotations or per-rule `paths:` excludes — **that set cannot be enumerated in advance**, so budget for it. **`scripts/security/scan_forbidden.py` is OFF-LIMITS to this session** — it is being rewritten in the same wave by `leakgate-detector-blindspots`, which carries any annotation the widened scan needs there. The other newly-scanned files are **not unowned**; §C names their real owners and the announce-in-the-PR-body rule. | +| 1 | `leakgate-detector-blindspots` | #325 + #321 | 1.5 | `scripts/security/scan_forbidden.py`, `tests/test_scan_forbidden.py`, `tests/test_scan_tokens_source.py`, `scripts/security/scan-tokens.local.txt.example`, `scripts/security/scan-allowlist.txt`, `messagefoundry/anon/leak.py` (read) | **Combined: both edit `scan_forbidden.py`** — one owner, one PR. Their *test* files do not collide (#325 → `test_scan_tokens_source.py:559`; #321 → `test_scan_forbidden.py`); the item's own Related line mis-cites this and it is worth knowing. **#321 is AMENDED 2026-08-03 and the amendment is confirmed against the tree** — per-class hit tests already exist at `test_scan_forbidden.py:83/:91/:107/:126/:136/:152`, so the "no negative test exists" premise is FALSE: scope to Proposed 1 (owner data) + Proposed 3 (shape backstop) only. Keep the exemption list **case-SENSITIVE** — case-folding it widens exemptions, the under-detection direction. The `_NEVER` sentinel (`:111`, `:453-454`) is why a no-token run is silently blind — the backstop must be prefix-free so it fires without the private file. **Also carries any `nosemgrep` annotation `scan_forbidden.py` needs for #334's widened scan** (it is a regex- and `subprocess`-dense module and a prime candidate); coordinate the text with #334 in the PR bodies, but the edit lands here. | +| 1 | `phi-at-rest-assertion-strength` | #347 | 0.5 | `tests/test_store_encryption.py`, `tests/test_content_search.py` | `:303` is a **compound** assertion — `startswith(MARKER_PREFIX) and "999001" not in sm and "DOE" not in sm`; only the `DOE` clause is in scope, and `:304`'s `"WESTWING" not in md` stays. **Falsify before trusting the green:** hand the store an `IdentityCipher` or plant a plaintext body, watch the rewrite go RED, restore. Write the ≥6-char rule into the convention comment at `:49-58`. No ADR — the ADR 0158 follow-up is deliberately deferred until 0158 is on `main` (0158 IS present in this worktree; do not file early). | +| 1 | `logging-controlchar-escapes` | #335 | 1 | `messagefoundry/logging_setup.py`, `tests/test_logging.py`, `tests/test_asvs_phase0.py`, `tests/test_wiring_engine.py`, `docs/adr/0034-*.md`, `docs/PHI.md`, `docs/ASVS-L2-PHASE0-CHANGES.md` | Apply `_CTRL_TRANSLATION` (`:65-69`) to `record.exc_text` + `record.stack_info` inside `ControlCharScrubFilter.filter` (`:81-87`); re-join with a fixed non-empty continuation prefix so no line starts at column 0 and matches `_LOG_FORMAT` (`:54`). **`tests/test_wiring_engine.py:362-373` builds a capture handler wearing the PRODUCTION filter chain and asserts on its output — a re-join prefix changes that text. Live break risk, not a bystander.** ADR 0034 §1 (`:144-147`) currently reads "Open hardening (not done)" — amend it, do not drive-by edit. | +| 1 | `ide-aiassist-gate` | #330 | 1 | `ide/src/aiPolicy.ts`, `ide/src/engineStatusModel.ts`, `ide/src/test/suite/ai-policy.test.ts`, `ide/src/test/suite/engine-doctor.test.ts`, `docs/AI.md`, `docs/adr/0035-*.md` | **Order matters:** guard the unconditional `LAST_POLICY_KEY` write at `aiPolicy.ts:79` FIRST so a null cannot downgrade a cached `false`, *then* attach `peekToken` (`auth.ts:93` — never `ensureToken`) as `getJson`'s third arg at `:78` behind `assertTargetAllowed`. `statusBar.ts:312` stays tokenless **on purpose** (CWE-613 idle clock) — express the distinction as a plan constant beside `POLL_PLAN`/`VERIFY_PLAN` (`engineStatusModel.ts:146/:152`, asserted at `engine-doctor.test.ts:96-120`). `AI.md:188` publishes a deny row no code path produces — re-check it only after the gate can fire. **npm/mocha leg, not pytest.** | +| 2 | `steps-note-rows-and-param-schema` | #248 + #235 | 4 | `messagefoundry/lens.py`, `messagefoundry/actions.py`, `messagefoundry/diagnostics.py`, `messagefoundry/__main__.py`, `ide/src/stepsModel.ts`, `ide/src/stepsView.ts`, `ide/media/stepsWebview.js`, `scripts/quality/lens_coverage.py`, `docs/STEPS-PALETTE.md`, `tests/test_actions.py`, `tests/test_lens_parse.py`, `tests/test_lens_rewrite.py`, `tests/test_lens_palette.py`, `tests/test_lens_native.py`, `ide/src/test/suite/steps.test.ts` + `steps-addmenu.test.ts` + `steps-edit.test.ts`, `docs/adr/0076-*.md`, `docs/FEATURE-MAP.md` | **Combined: both edit `lens.py` + `__main__.py` + `stepsModel.ts` + `stepsWebview.js` + the same three lens test suites.** #248: **ADR 0076 Amendment A §A.7 REQUIRES the `note` emission be gated behind a flag or contract version** — the emission point is the `lens parse` subparser at `__main__.py:375-390`, invoked by `stepsView.ts:298` as `lens parse -`. That was the single most dangerous omission in the original map. Amendment A is **Accepted and in force**; Amendment B (helper descent) is **DECLINED — do not build it**. Notes must be **excluded from #239's editable-share numerator**. #235: a `lens schema` sibling is an edit to the CLI's hottest file and **no existing test drives the lens CLI**; `diagnostics.py`'s `log_note`/`checkpoint` render as rows via `_DIAGNOSTIC_PARAMS` (`lens.py:103-111`), so an `actions.py`-only schema leaves two row kinds on the hand-rolled path. **Owner must settle #235's engine-emitted-schema vs IDE-hardcoded fork before build** — it decides whether this is a two-repo-half change. | +| 2 | `ui-editor-phi-gate` | #324 | 1 | `messagefoundry_webconsole/routes/core.py`, `messagefoundry_webconsole/_auth.py`, `messagefoundry/auth/permissions.py`, `messagefoundry/api/_ui_seam.py`, `docs/SECURITY.md`, `packaging/messagefoundry-webconsole/tests/test_webui.py`, `tests/test_security_doc_drift.py` | **P1.** Gate `/ui` edit on `MESSAGES_VIEW_RAW` **as well as** `MESSAGES_EDIT` (`core.py:597-602`, `:609-620`, and the step-up continuation at `:76`), threading `phi=` through `require_ui_step_up` (`_auth.py:496`) into `require_ui` (`:205`, whose phi arm is the `:260` `allow_phi_read` throttle). **`test_security_doc_drift.py:86` pins `_MULTI_PERMISSION_ROUTES = {("GET","/messages/export"), ("GET","/ui/alerts")}` — a two-permission edit route must be added there or the drift guard reds independently of the doc edit.** Code + `SECURITY.md` (`:213`, `:474-477`) land in ONE commit. `_ui_seam.py:100` is the source of the "re-asserts the equivalent permission" claim this defect falsifies — **prose only, do NOT bump `ENGINE_UI_SEAM`**. Option 2 (adding `MESSAGES_EDIT` to `CUSTOM_ROLE_FORBIDDEN_PERMISSIONS`, `permissions.py:178-180`) is an ADR 0045 D1 amendment — owner picks. **Run BOTH pytest paths.** | +| 2 | `sandbox-stderr-relay` | #343 | 1.5 | `pipeline/sandbox.py`, `pipeline/_sandbox_worker.py`, `config/settings.py`, `tests/test_sandbox.py`, `docs/CONFIGURATION.md`, `docs/adr/0087-*.md` | `stderr=None` + its inline comment is at `sandbox.py:266`, inside `_spawn`'s `Popen`, beside the **existing** stdout reader thread (`_reader_loop`, `:274-277`) the new relay should mirror. `_sandbox_worker.py:40-41` pins the root logger to stderr and `:195` takes `sys.stdout.buffer` as the exclusive frame channel — close the fd-1 landmine by redirecting the child's `sys.stdout` to stderr at bootstrap. `SandboxSettings` at `settings.py:1220`; `CONFIGURATION.md:791-829` documents `[sandbox]` (+ index at `:51`) — any knob lands there in the same commit. **`tests/test_sandbox.py` has ZERO stderr/Popen assertions today** — new coverage, not an edit. ADR 0147 is Proposed with no code — **not** a required edit. | +| 2 | `ci-step-margin-bounds` | #344 | 1.5 | `.github/workflows/ci.yml`, `.github/workflows/quality-advisory.yml`, `docs/CI.md`, **NEW** `scripts/ci/check_step_margin.py`, `scripts/ci/check_required_workflow_state.py`, `tests/test_quality_advisory_invariants.py`, `tests/test_security_posture.py`, `tests/test_stage_dispatcher.py` (stale-handoff cleanup only) | **Instance 2 is ALREADY MERGED at HEAD** — `test_stage_dispatcher.py`'s `_wait_until` raises `_WaitTimeout` with a full dump and cites "BACKLOG #344 proposal 6". **Do NOT rebuild it.** The root `HANDOFF-344-instance-2.md` still calls it unpushed — that file is STALE; delete or correct it in this PR. **Proposal 3 is WITHDRAWN — do not implement the virtual-clock deadline.** New script must time the **STEP**, key on the **step's own conclusion**, read `?filter=all` for the right-censored max. Give `Web console tests (pytest)` (`ci.yml:446-448`) its own `timeout-minutes` instead of sharing `matrix.step_timeout` (`:575-577`). `test_quality_advisory_invariants.py` pins quality-advisory as advisory-and-never-promoted; `test_security_posture.py` refuses `continue-on-error`/`\|\| true` on any job backing a required context — **one of them fires whichever workflow the new job lands in.** | +| 2 | `dependabot-automerge-allowset` | #336 | 1.5 | `.github/workflows/dependabot-auto-merge.yml`, `.github/dependabot.yml`, `tests/test_dependabot_automerge_guardrails.py` | **AMENDMENT RE-VERIFIED: the github-actions cooldown HAS shipped** (`dependabot.yml:83-84`, `default-days: 5`; uv `:34`, npm `:106`) — Proposed §2 is discharged, **do not rebuild it**. Consequence: `.github/dependabot.yml` may need **no edit at all**; it stays on the map only so a scheduler treats it as contended. At least four in-item citations (`:16-18`, `:58`, `:78-79`, `:155-161`) now point at different lines — the deny-list is `:84-85`, the merge gate `:181`. **Re-measure before quoting.** Effort raised 1 → 1.5: §4 (a workflow-side release-age check that queries a registry and fails closed beside the GHSA step at `:136-145`) is a new API-calling step with its own failure semantics. **Write the header's semgrep-scope sentence scope-neutral** — W1's #334 rewrites what it describes. | +| 2 | `leakgate-placeholder-guidance` | #322 | 0.5 | `scripts/security/scan-tokens.local.txt.example`, `CONTRIBUTING.md`, `tests/test_scan_tokens_source.py`, `scripts/dev/setup-leak-gate.ps1` | **`tests/test_scan_tokens_source.py` parses the `.example` file structurally (`_EXAMPLE` at `:30`) and asserts it compiles to exactly `{acme, exampleco, examplevendor}` (`:114`) — a guidance paragraph dropped inside a section body will red it.** Put the text in the header/comment region. Test under **both** the real and the synthetic token set — the pre-commit hook passes `--require-tokens`, and this item exists because that step was skipped once. **`scan_forbidden.py` is READ-ONLY for this session** (the three-state load banner at `:846-856` already covers the optional half). If the owner wants the per-hit reason string too, this item moves to a wave after W3 — it would collide with `anon-structural-detectors`. | +| 2 | `handler-security-lint-recall` | #337 | 1.5 | `messagefoundry/checks.py`, `messagefoundry/security/semgrep/handler-security.yml`, `tests/test_checks_handler_security.py`, `tests/test_checks.py`, `tests/test_semgrep_handler_rules.py`, `tests/fixtures/handler_taint/handler-security.py`, `docs/adr/0144-*.md` | Resolve `getattr(, "const")` in `_ambient_authority_hit` (`checks.py:690-721`) and widen phi-to-log past the decorated-scope gate (`:921-934`) into `_*.py` helpers. **`tests/test_checks.py` runs `check` against `samples/config` — a phi-to-log false positive on `_demo_oru_transforms.py` or `_pdf_mdm_transforms.py` reds the shipped-samples gate. Prove green against samples BEFORE landing; the samples are READ-ONLY calibration inputs, never edit targets.** The non-recursive glob (`:893`/`:898`) is explicitly **out of scope** — it belongs to #226. ADR 0144 exists and is amended in place; `docs/adr/README.md` needs no row. | +| 2 | `ide-symbol-index-breadth` | #228 | 1 | `ide/src/graphTree.ts`, `ide/src/symbolIndex.ts`, `ide/src/graphModel.ts`, `ide/src/extension.ts`, `ide/package.json`, `ide/src/test/suite/symbol-index.test.ts`, **NEW** `ide/src/test/suite/graph-tree.test.ts` | (a) is near-trivial: `graphTree.ts:47-48` already forwards `vm.contextValue` and `package.json:428` already gates "View as Steps" on `viewItem == meforElementHandler` — the Definitions rows at `:268-280` simply never set it. (b) is the real work: **widening `SymbolKind` forces a matching edit to the EXHAUSTIVE `Record SYMBOL_ICON` at `graphTree.ts:248` — TypeScript will not compile without it** — plus indexing `Send()` targets, which the current top-level-def regex cannot see. `graphModel.ts` is the canonical home of the `contextValue` vocabulary (`:327/:360/:384/:447/:471`, `VmNode.contextValue` at `:67`) — defensive, likely read-only, but hot. **This session owns `ide/package.json` in W2.** | +| 2 | `ingress-stranding-under-load` | #320 | 1.5 | `harness/load/ingress_probe.py`, `harness/load/report.py`, `.github/workflows/ingress-rate-probe.yml`, `tests/test_load_runner.py`, `tests/test_harness_reconcile.py` | **CORRECTION to the item's premise: `report.py:597-610` is NOT still citing the retracted "~16% / 3× the worst seen".** That comment was already rewritten — it now presents 16%/half as the FORMER, WRONG rationale ("THAT FRACTION WAS HALF, AND HALF WAS THE BUG") and justifies three quarters as "~1.5× the worst healthy value now on record (51%)". Residual: fold in the 36-run sweep distribution and reconcile "51% = worst healthy" against this item's finding that healthy stranding at 60/s is **0%**. `test_load_runner.py` (`acked >= sent // 4`) and `report.py` encode the same tolerance in two places **by design** — one session touches both, never split across waves. **Diagnosis-only, ships no product change: agree exit criteria before starting or it has no definition of done.** The decisive run needs the self-hosted WS2025 rig (never run, runner unregistered) — the **hosted contention arm is the schedulable half**. | +| 2 | `store-batched-transform-handoff` | #214 | 3 | `store/base.py`, `store/store.py`, `store/sqlserver.py`, `store/postgres.py`, `pipeline/wiring_runner.py`, `tests/test_transform_concurrency.py`, `test_staged_pipeline.py`, `test_seq_only_fifo.py`, `test_adr0075_rt_count_gate.py`, `test_adr0075_batch_golden_sql.py`, `test_adr0071_statement_rt_inventory.py`, `test_sqlserver_sync_handoff.py`, `test_store_once_deliver_many.py`, `docs/adr/README.md`, `docs/ARCHITECTURE.md` | **Residual (a) COMMIT-COLLAPSE only. Residual (b) is DECLINED — do NOT add a `transform_concurrency` setting.** Batched multi-row `transform_handoff` replacing today's single-row `routed_id: str` protocol (`base.py:341`) across all three async backends + the sync variant at `sqlserver.py:4317` + its caller `wiring_runner.py:638`, preserving claim→produce→complete atomicity, FIFO seq order and at-least-once. **Blast radius is much larger than the item implies: 37 files under `tests/` reference `transform_handoff`** — the golden-SQL gate and the round-trip inventories will all move. **A NEW ADR, allocated with `alloc.ps1`, with its `docs/adr/README.md` row in the SAME commit.** 3-backend migration; the SQL Server + Postgres legs **skip silently** locally. **#209 is named as a dependency but is already CLOSED — not a blocker.** Owner-deferred 2026-07-24 and marked ULTRACODE: **owner sign-off before build.** | +| 3 | `steps-router-rows` | #232 | 3 | `messagefoundry/lens.py`, `scripts/quality/lens_coverage.py`, `samples/config/IB_DEMO_ORU_router.py`, `ide/src/stepsModel.ts`, `ide/src/stepsView.ts`, `ide/src/editorToolbar.ts`, `ide/media/stepsWebview.js`, `ide/package.json`, `ide/src/test/suite/steps.test.ts` + `steps-addmenu.test.ts` + `steps-edit.test.ts` + `editor-toolbar.test.ts`, `tests/test_lens_parse.py` + `test_lens_rewrite.py` + `test_lens_control.py` + `test_lens_palette.py`, `docs/STEPS-PALETTE.md`, `docs/testing/master-test-plan/13-steps-editor.md`, `docs/adr/0076-*.md` | **ADR-FIRST, hard gate: widening the ROW GRAMMAR requires amending ADR 0076 §2 ("widening the roster is an ordinary addition, widening the grammar requires amending this ADR"). The amendment lands before any build.** `lens.py:305` skips routers outright (`continue # not a @handler (router or plain def) — out of v1 scope`). **`editor-toolbar.test.ts:62` asserts `hasHandler('@router("IB")\ndef route(msg): ...') === false` — that assertion INVERTS under this item. It is a pre-existing red-by-design, NOT a flake; nobody should "fix" it.** `steps-edit.test.ts:1070-1073` asserts every `ADD_MENU_CATALOG` item appears under Insert before/after — a new router palette group changes it. `messagefoundry/actions.py` was considered and left off (a router row returns handler names, not a vocabulary helper) — revisit only if the amendment introduces a `route()` helper. | +| 3 | `tls-loosening-registry-visibility` | #333 | 2 | `config/wiring.py`, `config/settings.py`, `config/models.py`, `transports/database.py`, `messagefoundry/checks.py`, `api/app.py`, `messagefoundry/__main__.py`, `ide/src/securityEditor.ts`, `tests/test_security_posture_defaults.py`, `test_database_transport.py`, `test_tls_expiry_relaxation.py`, `test_security_config.py`, `test_alert_smtp_tls.py`, `test_client_network_allowlist.py`, `test_memory_encryption_readout.py`, `docs/DEPLOYMENT.md`, `docs/SECURITY-LOOSENING.md`, `docs/CONNECTIONS.md` | **Widest blast radius in the set.** **Ordering inside the item: step 1 (fix the value-blind ODBC detector so `SSLmode=disable` WARNs) MUST precede steps 3-4, or the new surfaces inherit the false negative and report the worst real case as clean.** **CALLER COUNT CORRECTED — three production callers of `security_loosenings()`, not four:** `api/app.py:1524`, `__main__.py:1468`, `__main__.py:4490`. **`checks.py` contains NO call today** — Proposed §5 is new sibling advisories modelled on `_check_cleartext_accepted` (`:1317-1360`). **TEST CALL SITES RE-CENSUSED: adding two required params breaks NINE call sites across FIVE files** (`test_alert_smtp_tls.py:176/:210`, `test_client_network_allowlist.py:53`, `test_memory_encryption_readout.py:60`, `test_security_config.py:37`, `test_security_posture_defaults.py:63/:94/:110/:123/:271`) — all move in the same commit. **`messagefoundry_webconsole` needs NO edit** — `pages/monitoring.py:492-499` iterates `posture.loosenings`, so new registry entries flow through generically. **No new ADR**; do NOT convert the generic-ODBC delegation into a refusal. **Two dead ADR links to fix in the same BACKLOG edit — see §D.** | +| 3 | `release-signing-hashed-locks` | #332 | 2 | `.github/workflows/release.yml`, `.github/workflows/security.yml`, `.github/workflows/dependabot-lock-resync.yml`, `pyproject.toml`, `uv.lock`, `requirements.lock`, `constraints.lock`, `ci/locks/ci-scanners.lock`, `ci/locks/ci-quality.lock`, **NEW** `ci/locks/release-tools.lock`, `docker/locks/requirements-core.lock` + `-sqlserver.lock`, `tests/test_ci_venv_pinning.py`, `tests/test_dep1_lock_resync_lockstep.py`, `tests/test_release_pipeline.py`, `docs/SUPPLY-CHAIN.md`, `docs/adr/0034-*.md` | PEP 735 `release-tools` group + a **seventh** hashed lock; install sigstore (then build/cyclonedx-bom) with `--require-hashes` at `release.yml:255/:104/:207`. The six-place export lockstep becomes **seven-place**. **`tests/test_release_pipeline.py` pins the release job's step markers AND their ORDER (`idx("python -m sigstore sign")` at `:236`, the SBOM marker at `:160`, `test_sbom_install_is_byte_identical_in_release_and_security`) — replacing the install steps moves those markers and reds it.** **`.gitattributes` needs NO line** (`ci/locks/*.lock text eol=lf` already covers it). ADR 0034's residual row at `:350` keeps sigstore out of the lock **BY DECISION** — confirm sigstore 4.5.0's PyPI publish date against the 5-day cooldown and **amend rather than silently invert**. **No PR CI leg executes `release.yml`** — first real run is a tag; dry-run via `workflow_dispatch` per ADR 0034:218-225. Re-export ALL locks; `git diff --exit-code` → 0. Resolve contamination may force the excluded-by-decision call semgrep got, turning this into a residual, not a build. | +| 3 | `sandbox-process-tree-reap` | #342 | 2 | `pipeline/sandbox.py`, `pipeline/_sandbox_worker.py`, `tests/test_sandbox.py`, `docs/adr/0087-*.md`, `docs/adr/0147-*.md` | `SandboxSession._kill` (`sandbox.py:323-327`) is a plain `proc.kill()` + `wait(timeout=5)`; spawn at `:262-269` must go into a kill-on-close **job object** on Windows and a **new session** on POSIX (`start_new_session=True`, then `killpg`), so a Handler-spawned grandchild inheriting fd 1 — the response pipe — cannot survive as an orphan. No settings key → `config/settings.py` stays out. **`tests/test_threat_model_doc_drift.py:762` pins a `pipeline/sandbox.py` description against `docs/security/THREAT-MODEL.md`, a VAULT doc absent from this checkout (`docs/security/` is gitignored) — the test cannot run here. Do NOT schedule an edit against it; the description may need a vault-side update the owner makes separately.** **The sandbox path is off by default** (`[sandbox].mode != subprocess`) — the new test must force the mode on or it proves nothing. **Windows CI AND Linux CI both required** — the job-object half and the `killpg` half each prove nothing about the other. | +| 3 | `anon-structural-detectors` | #331 | 2 | `messagefoundry/anon/{leak,__init__,rules,surrogates,hl7}.py`, `tee/anon/{leak,__init__,rules,surrogates,hl7}.py`, `scripts/security/scan_forbidden.py`, `tests/test_anon_core.py`, `tests/test_anon_parity.py`, `tests/test_anon_integration.py`, `docs/adr/0030-*.md` | **MIRROR FILES ARE MANDATORY: `tests/test_anon_parity.py:24` pins `_BYTE_IDENTICAL = ("keying.py", "rules.py", "surrogates.py")` — any edit to `messagefoundry/anon/rules.py` or `surrogates.py` must land BYTE-IDENTICALLY in `tee/anon/`.** Proposed §4 edits `rules.py` directly, so the pair is not optional; landing one side is a silent divergence. `anonymize_message` lives in `anon/hl7.py` (+ its tee twin) — the unmapped-field report is only derivable from that applier, not from `__init__.py`. **Expect to fight `test_anon_parity.py:145` (`test_anon_files_do_not_self_trip_the_publish_guard`)** — new SSN/phone/MRN regexes written into `anon/*.py` can make the anon sources themselves trip `scan_file`. `token_floor_failure()` is at `scan_forbidden.py:547`, reached only from `main()` at `:881`; the module-level `reload_tokens()` at `:671` — the anonymizer import path — never consults it. Effort raised 1.5 → 2 for the five mirror files + the self-trip guard. | +| 3 | `cluster-failover-margin-triage` | #351 | 1 | `tests/test_cluster_failover_sqlserver.py`, `tests/test_cluster_failover_postgres.py`, `pipeline/cluster.py`, `pipeline/cluster_sqlserver.py`, `docs/adr/0159-*.md` | **Verdict is TRIAGE — "do not fix by widening the margin".** `_TTL=1.0` at `cluster_sqlserver.py:38`, sleep at `:228`, `acquire_delay_seconds=0.5` at `:231`. **`test_cluster_failover_postgres.py` carries the BYTE-IDENTICAL pair at `:224/:227` under the same test name — any structural fix lands on BOTH or the same latent red survives on the Postgres leg.** The delay predicate for the generic/Postgres `DbCoordinator` lives in `pipeline/cluster.py` (`self._acquire_delay` `:466`, handicap contract `:949-957`, SQL `:972-980`) — if triage lands on injecting/freezing the reference instant, the seam is cut in BOTH coordinators. **Local pytest SKIPS this module outright** (`pytestmark skipif` on `MEFOR_TEST_SQLSERVER`); it cannot be reproduced on a dev box. **Owner sign-off:** discriminating a marginal test tipped by #348/ADR 0159's added `_acquire` latency from a real regression needs that change's author. | +| 4 | `steps-per-argument-modes` | #237 | 3 | `messagefoundry/lens.py`, `ide/src/stepsModel.ts`, `ide/src/stepsView.ts`, `ide/media/stepsWebview.js`, `ide/src/hl7Picker.ts`, `ide/src/hl7scope.ts`, `scripts/quality/lens_coverage.py`, `tests/test_lens_parse.py` + `test_lens_native.py` + `test_lens_palette.py`, `ide/src/test/suite/steps-edit.test.ts` + `steps.test.ts`, `docs/adr/0089-*.md`, `docs/adr/0076-*.md` | **HARD DEPENDENCY: #233 must have MERGED (W1).** This is not file contention — both banners record it as a stated prerequisite for touching this form surface. **RE-PRICED and RE-VERIFIED: `lens.py` computes a BINARY split per argument only** (`_ACTION_PARAMS` `:71`, `_LOOKUP_PARAMS` `:89`, `_DIAGNOSTIC_PARAMS` `:107`, rendered as `params`/`literal_params` at `:749-750`/`:769`) — the ten-way classifier is **new work in the shipped lens, not an exposure**. The banner's 4/10 was priced against a field that does not exist; **the 2026-08-03 amendment demands an owner RE-PRICE before scheduling.** A per-argument mode selector cannot be added in model+webview alone — `stepsView.ts` owns the webview host and the extension↔webview plumbing (CSP `default-src 'none'` at `:917`). ADR gate: the ten-way value class is a NEW row-contract element — amend **both** ADR 0089 §5 and ADR 0076 §3. | +| 4 | `tls-kex-seam-readout` | #338 | 1 | `docs/CONTAINER-EXPOSURE-EVALUATION.md`, `docs/ASVS-L2-PHASE0-CHANGES.md`, `docs/archive/backlog/BACKLOG-CLOSED.md`, `api/models.py`, `api/app.py`, `api/tls.py`, `api/_ui_seam.py`, `messagefoundry_webconsole/__init__.py`, `messagefoundry_webconsole/pages/monitoring.py`, `tests/golden/webconsole_seam.snapshot`, `tests/test_webconsole_seam_snapshot.py`, `tests/test_webconsole_monitoring_fips.py`, `tests/test_api_security_posture.py`, `packaging/messagefoundry-webconsole/tests/test_webui.py` | **The `ENGINE_UI_SEAM` bump (17 → 18) is a coordinated MULTI-FILE edit that must ride ONE commit** — `api/_ui_seam.py:91` **plus** `messagefoundry_webconsole/__init__.py:48` (`SUPPORTED_ENGINE_SEAMS`) — or `mount_ui` refuses at startup. Full procedure: `docs/WEBCONSOLE-PACKAGE.md` §"Bumping the seam" (`:135-159`). **`tests/golden/webconsole_seam.snapshot` is MANDATORY and must be REGENERATED, never hand-edited** (`python scripts/webconsole_seam_snapshot.py > tests/golden/webconsole_seam.snapshot`) — it introspects `SecurityPosture`'s field list live and carries `ENGINE_UI_SEAM` at its head, so BOTH lines change. `api/tls.py:55` DISCARDS `harden_kex_groups`' return value — that is where the value must be captured. **Rule 5 FORBIDS touching `tests/test_tls_policy.py:117` and `tests/test_api_tls.py:1278/:1318`** — deliberate Python-3.15 tripwires, left red-on-3.15 by design. `docs/PHI.md:638` already scores the cell PARTIAL — link, never restate. **Run BOTH pytest paths.** | +| 4 | `ci-merge-queue-preconditions` | #340 | 1 | `.github/workflows/ci.yml`, `security.yml`, `cla.yml`, `backlog-hygiene.yml`, `required-workflow-state.yml`, `.github/required-contexts.txt`, `tests/test_required_contexts.py`, `tests/_workflow_contexts.py`, `tests/test_required_workflow_state.py`, `scripts/ci/check_required_workflow_state.py`, `scripts/ci/check_stalled_prs.py`, `docs/CI.md`, `docs/design/freethread.md`, `docs/WORKTREES.md` | **VERIFIED: `grep -rln merge_group .github/` returns NOTHING across 21 workflow files.** 13 required contexts across ci.yml (4), security.yml (7), cla.yml (1), backlog-hygiene.yml (1). **SEQUENCING IS SAFETY-CRITICAL: enabling the queue before the `merge_group:` triggers land wedges every open PR** (zero required contexts would report on `gh-readonly-queue/**`). **Step 2 must MERGE first; steps 1 and 3 are server-side branch-protection changes only the owner can make.** **CORRECTION: step 2 does NOT require moving the pinned count at `test_required_contexts.py:101` (`len(contexts) == 13`)** — a `merge_group` trigger changes *where* a context can report, not the required set. Do not let a session "reconcile" a count that does not need reconciling (ADR 0158 `:227/:304` also states 13 in prose). Editing `required-contexts.txt`'s comment block puts all eight `_CLAIM_FILES` in scope — a missing path is an ERROR, not a skip. | +| 4 | `sandbox-import-boundary` | #346 | 1 | `pipeline/sandbox.py`, `pipeline/_sandbox_codec.py`, `pipeline/_sandbox_worker.py`, `docs/adr/0087-*.md`, **NEW** `tests/test_sandbox_import_boundary.py` | **Falsification is a BUILD CONDITION, not a nicety:** run the new test against a deliberately introduced forbidden import and watch it go RED *before* trusting its green — an import-walker that resolves nothing goes green for exactly the same reason a correct one does. Re-verified: no test in `tests/` references `FORBIDDEN_MODULES` in any form, and the constant lives in `sandbox.py`, so the walker must **import** it, never copy it. **Structural fact the map must carry: `_sandbox_worker.py` imports back out of the child module (`from messagefoundry.pipeline.sandbox import _read_frame_bytes, _write_frame`)** — a transitive first-party walk necessarily descends into `sandbox.py`; the walker must handle that edge without reporting the parent as a violation, **and must print what it walked**. | +| 4 | `graph-export-formats` | #249 | 1 | `messagefoundry/__main__.py`, `messagefoundry/config/graph.py`, `tests/test_cli.py`, `tests/test_graph_static.py`, `docs/USER-GUIDE.md`, `docs/FEATURE-MAP.md`, `docs/testing/master-test-plan/07-config-wiring-and-cli.md` | **OWNER SIGN-OFF IS THE GATE — the verdict is literally "owner decision pending — proposed, not approved". Do not dispatch this session without a go.** **Title-vs-body mismatch, NOT a phantom file:** the item is titled `lens graph` but no such subcommand exists (`lens` has only parse/rewrite, `__main__.py:374-404`); the body correctly targets the **top-level `graph --json`** command (parser `:156-159`, handler `_graph` at `:2765`), which does exist. **Confirm the intended surface with the owner before building** — a `lens graph` subcommand would be a different, ADR 0076-adjacent change. `ide/src/graphTree.ts` consumes `graph --json` v2 — **read-only; a new `--format` is additive and must not change the JSON shape.** | +| 4 | `openflow-attribute-completeness` | #238 | 0.5 | **NEW** `docs/research/openflow-step-attribute-completeness.md`, `docs/STEPS-PALETTE.md` (speculative) | **A review, not a feature.** Walk retry / timeout / stop_after_if / skip_if / continue_on_error / mock / cache_ttl against the engine and record covered / not-covered / deliberately-declined per attribute (retry+timeout live in `config/models.py` `RetryPolicy` and delivery semantics, not in a handler row). The named deliverable does not exist — **that is correct, it is the output**; `docs/research/` itself exists. **Explicit non-goal that MUST be honoured: do NOT target OpenFlow compatibility or emit/consume the format** — a declarative artifact stays declined by ADR 0076 §7 and #26. `actions.py`, `config/models.py`, `lens.py`, ADR 0076 are **reads, no edits**. `docs/README.md:116`'s "~15 files" is approximate — no index row needed. | +| 5 | `merge-gate-negative-controls` | #1000 | 3 | `.github/required-contexts.txt`, `.github/workflows/ci.yml` (coverage job) + `security.yml` + `backlog-hygiene.yml` + `cla.yml`, `tests/test_required_contexts.py`, `tests/test_lint_scope_parity.py`, `scripts/security/scan-allowlist.txt`, `scripts/security/crypto_inventory_check.py`, `.gitleaks.toml`, `.semgrep/messagefoundry.yml`, `.pre-commit-config.yaml`, `docs/CI.md` | **SCOPE WARNING: #1000's 3 days is a CAP, not an estimate — 13 heterogeneous gates is several sub-projects; the recommendation to split it by gate family before dispatch STANDS.** #327 was **un-merged from this session by the adversarial review** precisely so the split stays cheap: advising a split while merging in another item made the split strictly harder. **The `cla` context has NO possible in-repo negative control** (third-party bot + PR-author signature state) — say so in the published gap count rather than inventing a fixture. **The scanner controls need exemption homes or the fixtures red the real gate** — `.gitleaks.toml`, `.semgrep/`, `scan-allowlist.txt`, **and `.pre-commit-config.yaml` (gitleaks + forbidden-content run as local hooks, so a planted fixture blocks every contributor's commit, not just CI)**. **`tests/test_lint_scope_parity.py` guards that ruff, bandit and the pre-commit hook agree on scope — a fixture dir excluded from bandit trips it.** Must avoid the #322 trap: a synthetic fixture that trips the real detectors. **Four files are shared same-wave with `private-paths-ignore-control` (`ci.yml`, `required-contexts.txt`, `.pre-commit-config.yaml`, `docs/CI.md`) — that session merges FIRST and this one rebases; see §C.** Owner sign-off for any new blocking context; protection FIRST, then the file, then the pinned count, all in one PR. | +| 5 | `private-paths-ignore-control` | #327 | 1 | `.gitignore`, **NEW** `tests/test_private_paths_stay_ignored.py`, `tests/test_release_pipeline.py` (`:38` PRIVATE_CANARY, `:117-127` groundedness), `tests/test_feature_map_claims.py`, `scripts/dev/setup-leak-gate.ps1` (`:58`), `docs/SECURITY-DOCS-POLICY.md`, `docs/SESSION-DRIFT-CONTROLS.md`, plus four **line-disjoint shares** with `merge-gate-negative-controls`: `.github/workflows/ci.yml` (`noncode` allowlist @`:664`), `.github/required-contexts.txt`, `.pre-commit-config.yaml`, `docs/CI.md` | **SPLIT OUT of #1000 by the adversarial review (2026-08-03).** It is self-contained at 1 day, it touches each shared file in **exactly one place**, and keeping it separate preserves the option to split #1000 later. **It MERGES FIRST in W5; #1000 rebases over it.** Anchor corrected: the `noncode` allowlist is at **`ci.yml:664`**, not `:472`/`:658`. `scripts/dev/setup-leak-gate.ps1:58` holds the **only** existing `check-ignore` call in the tree. `scripts/hooks/block-blanket-git-stage.ps1` is untracked-by-design — the item says **do NOT count it**. Same fixture caution as #1000: a planted private-path canary must not trip the real gitleaks/forbidden-content local hooks. | +| 5 | `dast-increment-2` | #318 | 3 | `scripts/security/dast_auth_sweep.py`, `route_gates.py`, `dast-policy.json`, `dast_target.py`, `.github/workflows/dast.yml`, `.github/workflows/nightly-notice.yml`, `tests/test_dast_auth_sweep.py`, `test_dast_claims.py`, `test_security_doc_drift.py`, `tests/test_nightly_notice.py`, `docs/adr/0155-*.md`, `docs/Secure_Development_Standards.md`, `pyproject.toml`, `requirements.lock` | **SCOPE WARNING: 3 days is a cap. Increment 2 is several sub-projects — split before dispatch. The nightly-notice follow-up is a clean 0.5-day slice to carve off first** (`nightly-notice.yml:24` is verified as `workflows: ["CI"]`; the name is pinned in `tests/test_nightly_notice.py`). **`tests/test_security_doc_drift.py:43` does `from scripts.security.route_gates import gate_of, route_rows` and `:556` states there is ONE implementation shared with the DAST sweep — any `route_gates` change (especially adding the `/ui` console plane, exactly increment 2's scope) reds it.** That is the shared derivation whose docstring warns it can "keep passing while measuring nothing" — **W2's #324 must be merged first** (smaller, gate-level). **`dast.yml` stays NON-required by design** (`required-contexts.txt:114-117`: no `pull_request` trigger, required-but-absent trap) — do NOT add a context. New dependency + **DEP-1 re-lock** (owner approval before any install). **BACKLOG #89's "ADR 0054 adversarial audit harness" DOES NOT EXIST — there is no mutator to extend for protocol fuzzing; do not plan against it.** | +| 5 | `steps-test-this-step` | #236 | 3 | `pipeline/dryrun_trace.py`, `pipeline/dryrun.py`, `messagefoundry/__main__.py`, `tests/test_dryrun_trace.py`, `tests/test_dryrun_snapshot_parity.py`, `ide/src/stepsModel.ts`, `stepsView.ts`, `ide/media/stepsWebview.js`, `ide/src/testBench.ts`, `ide/src/traceView.ts`, `ide/package.json`, `ide/src/test/suite/steps.test.ts`, `trace-view.test.ts`, `docs/adr/0072-*.md` | **`tests/test_dryrun_snapshot_parity.py`'s entire thesis is that `dryrun --trace` stays BYTE-IDENTICAL to plain `dryrun` (module docstring `:10`; imports `trace_dry_run` at `:38`) — a stop condition + state dump is precisely the change that breaks it, and it is the only thing that would catch it.** `traceView.ts` owns `buildTraceDetail`/`TraceEntry` (imported by `testBench.ts:14`, cached at `:89-90/:332-335`) — if the trace JSON grows a stop/state field, the type lives THERE. **`buildLensTraceArgs` (`stepsModel.ts:674`, consumed at `stepsView.ts:221`) must stay incapable of emitting `--show-phi`** — `steps.test.ts:302-304` already asserts that by name. **PHI review: the state dump must persist nothing and add no second gate;** the pin/mock is **mandatory by default for `db_lookup`/`fhir_lookup` rows**. Inherits #225's dirty-buffer misalignment (the trace reads disk, not the editor buffer). Spans BOTH CI legs. | +| 5 | `insecure-tls-clamp-cells` | #329 | 1.5 | `transports/remotefile.py`, `auth/ldap.py`, `pipeline/alert_sinks.py`, `transports/ai_broker.py`, `auth/service.py`, `api/app.py`, `config/settings.py`, `transports/rest.py` (read), `tests/test_remotefile_transport.py`, `test_ldap_timeouts.py`, `test_alert_sinks.py`, `test_ai_broker.py`, `test_hop_refusal_residuals.py`, `test_asvs_phase0.py`, `docs/DEPLOYMENT.md` | **CENSUS RE-VERIFIED AT HEAD — the item's evidence table is STALE and the build must not schedule against it.** Surviving raw `insecure_tls_allowed()` calls: `auth/ldap.py:113`, `pipeline/alert_sinks.py:291`, `transports/ai_broker.py:140`, `transports/remotefile.py:375`, plus `transports/database.py:112` (the documented unstamped fallback, EXCLUDED by the item). **`transports/direct.py` holds NO call — only comments at `:182-196`; `transports/email.py:126/:184` and `transports/mllp.py:512` are prose only.** **The naive fix ships GREEN AND INERT for the three out-of-gate cells** — `current_hop_posture()` is `None` there and `weakened_tls_escape_permitted(None)` returns True; the posture must be threaded **explicitly** through the `AuthService`/`create_app` constructors. One refuses-under-enforce+PHI-with-escape-set regression test **per cell** — without `test_ldap_timeouts.py`/`test_alert_sinks.py`/`test_ai_broker.py` the LDAPS cell, the highest-value one, has no test home at all. `transports/rest.py` (`refuse_cleartext_credential_hop` `:478-515`) is a READ reference. Surface the §4 SFTP host-key TOFU question in the PR; do not settle it. | +| 5 | `av-consult-prep` | #352 | 0.5 | `docs/ASVS-ASSESSMENT-METHOD.md`, `docs/ANTIVIRUS-FIREWALL.md`, `docs/CONNECTIONS.md`, `tests/test_asvs_file_surface_doc_drift.py` | **Not a build, and CANNOT be completed by a coding session alone** — it needs an external human (enterprise security). A session can only draft the four questions about AV coverage of engine-**initiated** SFTP/FTPS pulls and file-connector drop dirs, and stage the recording slot. **Owner sign-off: 5.4.3 is CLOSED by owner decision — `ASVS-ASSESSMENT-METHOD.md:67` ends "CLOSED by owner decision (2026-08-02); do not re-derive it".** **`tests/test_asvs_file_surface_doc_drift.py` pins `CONNECTIONS.md` file-surface/quarantine text for ASVS 5.1.1 — any edit to the AV section there can red it.** The scorecard of record lives in the private vault, **not this repo** — the repo edits are secondary and must not become a second scoring surface. `transports/file.py` (`set_scan_hook`/`scan_inbound_file`) and `remotefile.py` are read-only; no code change is in scope. | +| 5 | `throughput-roadmap-reconcile` | #64 | 0.5 | `docs/BACKLOG.md` (**item body 939-979 + its own banner ONLY — its ranked-table row is OWNER-ONLY, do not touch**), `docs/archive/throughput/THROUGHPUT-IMPROVEMENTS.md` (probably not) | **Pure prose reconcile, and almost all of it lands in this item's OWN body:** strike "Nothing builds before it" from step 1 (the measure-first run completed 2026-07-12) and mark step 2 group-commit **WITHDRAWN** (ADR 0055/0099; Phase 4 closed by ADR 0107), leaving steps 3-5 as the surviving index role. **CORRECTION: `THROUGHPUT-IMPROVEMENTS.md` may need NO edit at all** — its superseded banner (`:8-25`) already names §2 / §4 step 2 / §5 step 2 as stale AND states they are "left exactly as written: it is the record of the reasoning at the time". **Editing those bodies would CONTRADICT the banner — confirm intent first.** **Its ranked-table row is reconciled by the owner in the per-wave pass (§D RULE 1), not here** — this session's earlier claim on that row was withdrawn by the adversarial review. **Highest `docs/BACKLOG.md` share in the set — scheduled last on purpose.** | + +**Total: 37 sessions (8 in W1, 10 in W2, 6 in W3, 6 in W4, 7 in W5); 60.0 build-days; wall-clock ≈ five sequential waves, each wave internally parallel.** + +--- + +## B. Waves & sequencing + +**Wave 1 — 8 parallel worktrees, 11.0 d.** The gate-free start: the P1 accept-and-drop fix (`dryrun-fanout-contract`), the P1 CLI exposure predicate (`cli-exposure-and-audit-anchor`), the P1 leak-gate blind spots (`leakgate-detector-blindspots`), the Steps drop-model prerequisite (`steps-dropmodel-and-save-refresh` — **#233 gates W4's #237**), plus four self-contained lanes (`semgrep-scope-widening`, `phi-at-rest-assertion-strength`, `logging-controlchar-escapes`, `ide-aiassist-gate`). The only cross-session file is `docs/BACKLOG.md` — **and only its banner regions, now that the ranked table and the census are owner-only (§D RULE 1)**. `semgrep-scope-widening` and `leakgate-detector-blindspots` are kept file-disjoint by declaring `scan_forbidden.py` off-limits to #334. + +> ⚠️ **W1 carries ONE merge-order dependency, and it is a consequence of that de-confliction — do not treat W1 as fully order-free.** `leakgate-detector-blindspots` **merges BEFORE** `semgrep-scope-widening`. #334's widened semgrep run is a **blocking required context that must be clean in its own PR**, but any `nosemgrep` annotation needed in `scan_forbidden.py` is now carried by the leak-gate session — so if #334 runs first, a required check reds with **no remedy available inside the session that owns the failure**. Rebase #334 on the merged leak-gate work and re-run the widened scan before opening it. (The alternative, moving #334 to W2, costs a wave for nothing; this ordering is free.) +> +> **And #334's owned-file escape hatch is a `.semgrep` exclude, never an in-file annotation.** Every other owner of a newly-scanned file sits in a *later* wave than #334 — `scripts/quality/lens_coverage.py` (W2/W3/W4), `messagefoundry_webconsole/**` (W2/W4), `scripts/ci/check_required_workflow_state.py` (W2/W4), the DAST scripts and `crypto_inventory_check.py` (W5) — so "hand the annotation to the owning session" is **temporally unsatisfiable** for a W1 blocking gate. Where a newly-scanned file is owned by a later wave, #334 adds a per-rule `paths:` exclude in `.semgrep/messagefoundry.yml` (which it owns outright) and **records each exclusion in its PR body** so the owning session can convert it to a real fix later. An exclusion that is not recorded becomes permanent by forgetting. + +**Wave 2 — 10 parallel worktrees, 17.0 d.** The heaviest wave, and deliberately so: it carries the long poles whose CI round-trips dominate wall clock (`store-batched-transform-handoff` needs the SQL Server + Postgres legs; `ingress-stranding-under-load` needs the Windows probe matrix) alongside the second Steps slot and the P1 `/ui` PHI gate. **Gates that exist here:** `ui-editor-phi-gate` waits for W1 because `docs/SECURITY.md` is W1-owned; `sandbox-stderr-relay` waits for W1 because ADR 0087 is W1-touched by #341; `dependabot-automerge-allowset` waits for W1 so #334's semgrep-scope rewrite lands before its header comment describes it (**text coupling in a different file — write the sentence scope-neutral and this softens to a preference**); `leakgate-placeholder-guidance` waits because `test_scan_tokens_source.py` and the `.example` file are W1-owned. + +**Wave 3 — 6 parallel worktrees, 12.0 d.** The wide-blast-radius engine work: `tls-loosening-registry-visibility` (nine test call sites, three production callers, the value-blind detector that must be fixed FIRST), `release-signing-hashed-locks` (a seven-place lockstep), `sandbox-process-tree-reap`, `anon-structural-detectors` (ten mirror files under a byte-identity guard), `cluster-failover-margin-triage`, and the third Steps slot. **Gates:** `steps-router-rows` waits for its ADR 0076 §2 grammar amendment *and* for W2 to vacate `lens.py`; `tls-loosening-registry-visibility` waits for W1/W2 to vacate `settings.py` and `__main__.py`; `sandbox-process-tree-reap` waits for `sandbox.py` to clear W2. + +**Wave 4 — 6 parallel worktrees, 7.5 d.** `steps-per-argument-modes` (**the plan's only HARD dependency: #233 merged in W1**), the seam bump (`tls-kex-seam-readout`), the merge-queue precondition (`ci-merge-queue-preconditions`), the import-boundary guard, the owner-gated graph exporter, and the OpenFlow review. + +**Wave 5 — 7 parallel worktrees, 12.5 d.** The most-contended CI item (`merge-gate-negative-controls`, which co-owns files with #334/#332/#340/#344/#321 and therefore cannot run before all of them have cleared), its now-separate sibling `private-paths-ignore-control` (**merges first, ahead of #1000**), DAST increment 2 (waits on `test_security_doc_drift.py` clearing W2's #324), the last Steps slot, the TLS clamp cells (waits on `settings.py` + `api/app.py` clearing W1/W3/W4), and the two doc-only reconciles. + +**Strict chains (the only ones):** +- **#233 → #237** — a genuine hard dependency (both banners record it), `steps-dropmodel-and-save-refresh` (W1) → `steps-per-argument-modes` (W4). +- **`__main__.py` chain** — exactly one owner per wave, no exceptions: W1 `cli-exposure-and-audit-anchor` → W2 `steps-note-rows-and-param-schema` → W3 `tls-loosening-registry-visibility` → W4 `graph-export-formats` → W5 `steps-test-this-step`. **This chain is what fixes the wave count at five.** Seven items want that file; collapsing it needs the owner to merge two of those sessions. +- **Steps/lens chain** — seven items co-own `stepsModel.ts`/`stepsView.ts`; one session per wave, W1→W5. There is no way to parallelise them without co-owning within a session. +- **`sandbox.py` chain** — #343 (W2) → #342 (W3) → #346 (W4); ADR 0087 additionally claimed by #341 in W1. +- **`security.yml` chain** — #334 (W1) → #332 (W3) → #340 (W4) → #1000 (W5). +- **`ci.yml` chain** — #344 (W2) → #340 (W4) → **#327 then #1000 (W5, in that order — same wave, two sessions, line-disjoint and merge-ordered)**. +- **#340 step 2 must MERGE before the owner enables the merge queue** — this is safety-critical, not stylistic: enabling the queue with zero `merge_group:` triggers wedges every open PR. + +**Not a wave — owner gates that unblock nothing in-plan:** `#214` (owner-deferred + ULTRACODE), `#249` (verdict is *owner decision pending*), `#352` (needs an external human), `#321`'s data half, `#340` steps 1+3, `#1000`'s protection change. Each is placed where its *code* half is schedulable; none gates another session. + +--- + +## C. Contention matrix + +Every hotspot is held to **one owner per wave**. Banner positions verified on disk: #64 @939 · #214 @2344 · #228 @2364 · #232 @2384 · #233 @2410 · #234 @2434 · #235 @2452 · #236 @2470 · #237 @2486 · #238 @2507 · #248 @2523 · #249 @2549 · #318 @2565 · #320 @2585 · #321 @2656 · #322 @2689 · #324 @2718 · #325 @2777 · #326 @2836 · #327 @2892 · #328 @2943 · #329 @2982 · #330 @3037 · #331 @3098 · #332 @3142 · #333 @3200 · #334 @3250 · #335 @3292 · #336 @3353 · #337 @3407 · #338 @3468 · #340 @3514 · #341 @3615 · #342 @3633 · #343 @3649 · #344 @3572 · #346 @3667 · #347 @3746 · #351 @3810 · #352 @3718 · #1000 @3824. + +| Shared / hot file | Owner by wave | De-confliction | +|---|---|---| +| `docs/BACKLOG.md` — **banner regions only** | **All 41 items, all 5 waves** | **The first deliberate same-wave exception, and it is now strictly scoped to banners.** Each session edits ONLY its own item's banner region. **Measured, not asserted: the minimum inter-banner gap anywhere in the file is 14 lines** (#351 @3810 / #1000 @3824 — cross-wave), comfortably outside git's 3-line merge context. The only sub-30-line **same-wave** pair is #233 @2410 / #234 @2434 (24 lines), and those two items **share one session**, so no two worktrees are involved. **Owner serialises the merges within a wave.** _(An earlier draft called #341/#342/#343 at 16–18 lines an "exception" to a ≥14 rule — 16–18 satisfies ≥14, and #338 @3468 / #340 @3514 are 46 apart, not tight. Restated here rather than dropped, because a de-confliction claim that contradicts itself is not one a session can rely on.)_ | +| `docs/BACKLOG.md` — **the live ranked table (`:180-272`) and the Distribution census (`:169-171`)** | **OWNER-ONLY — no session, no wave** | **The second `docs/BACKLOG.md` surface, and the plan's original map missed it.** The table holds **93** rows — one per open item, **contiguous, one line each, zero gaps** — with the plan's 41 schedulable items interleaved among them, and they are present-tense build-state prose every session falsifies (row `:180` for #341 says `_partition` **still narrows**; `:194` for #344 says **no margin script exists**; `:193` for #328 says both surfaces call `verify_audit_chain()` **bare**). Wave-mates land on **adjacent lines** — W1 alone has `:180`/`:182`, `:191`/`:192`, `:208`/`:209`, `:233`/`:234`, all inside git's 3-line merge context. The 4-line census is worse: **one hunk touched by every session**, and two sessions each re-deriving it from a tree the other already decremented **merge clean and produce a wrong census** — the exact "merge clean, silently corrupt the ledger" failure this plan exists to prevent. **The file itself licenses the fix at `:165`: "the banner is the live record. This table is a view of it, and where the two disagree the banner wins."** So sessions update banners; **the owner runs ONE reconcile pass per wave, after that wave's merges**, re-deriving the table and recomputing all four census lines together. See §D RULE 1. | +| `tests/test_cutover_slug_rot.py` | **OWNER-ONLY — no session, no wave** | A **two-way** repo-wide prose ratchet over `git ls-files`: `_PROSE_CEILING = 54` (`:84`), the ceiling assert at `:175`, **and a slack check at `:197-200` that fails when the ceiling has unused slots** ("lower `_PROSE_CEILING` to {actual}"). It therefore fires on prose **removal** as well as addition — **any** session whose diff drops a `the mirror` / `public mirror` / `private repo` phrase is a candidate, not just the doc-heavy four. It appears in no session's Owns list by design: **route every ceiling change through the owner**, in the same per-wave pass as the ranked table. Two sessions adjusting it in parallel collide silently. | +| `ide/src/stepsModel.ts` · `ide/src/stepsView.ts` · `ide/media/stepsWebview.js` · `ide/src/test/suite/steps.test.ts` · `steps-edit.test.ts` · `steps-addmenu.test.ts` | **W1** steps-dropmodel (#233+#234) · **W2** steps-note-rows-and-param-schema (#248+#235) · **W3** steps-router-rows (#232) · **W4** steps-per-argument-modes (#237) · **W5** steps-test-this-step (#236) | Seven items, five waves, **exactly one owner per wave** — achieved by pairing (#233+#234) and (#248+#235) into single sessions. `steps-addmenu.test.ts` is claimed only in W2 and W3; `steps.test.ts` in every wave but W3-only via #232. **No session may reach into another wave's Steps file, even "just to add a test".** | +| `messagefoundry/__main__.py` | **W1** cli-exposure-and-audit-anchor · **W2** steps-note-rows-and-param-schema (#248 §A.7 flag + #235 `lens schema`) · **W3** tls-loosening-registry-visibility · **W4** graph-export-formats · **W5** steps-test-this-step | Seven items want the CLI's hottest file. **This is the constraint that sets the wave count at five.** #248's claim is non-obvious and was the most dangerous omission in the source map: ADR 0076 §A.7 *requires* the `note` emission be flag/version-gated at the `lens parse` subparser (`:375-390`). | +| `messagefoundry/lens.py` | **W1** dryrun-fanout-contract (`:1272`/`:2237` stale-comment review ONLY) · **W2** #248+#235 · **W3** #232 · **W4** #237 | W1's touch is a rationale/comment review — the tuple-drop is cited there as the reason for the accumulator recognizer's refusals ("A LIST only (never ())…the runtime's `_partition` keys on"). **If that review turns into a logic change, it must move to W5.** | +| `scripts/quality/lens_coverage.py` | **W2** #248 (notes bucket, Amendment A §A.8) · **W3** #232 (coverage partition) · **W4** #237 | Three row-kind items, three waves. | +| `tests/test_lens_parse.py` · `test_lens_palette.py` · `test_lens_rewrite.py` · `test_lens_native.py` | **W2** #248+#235 · **W3** #232 · **W4** #237 | `test_lens_native.py` is W2/W4 only; `test_lens_rewrite.py` W2/W3 only. All four are ADR 0089/0106 row-contract tests — **a new emitted key breaks them or must extend them**. | +| `docs/adr/0076-typed-action-vocabulary-action-list-lens.md` | **W1** #234 (§5 save-gate amendment) · **W2** #235 (§3 row contract) · **W3** #232 (§2 grammar — **ADR-first hard gate**) · **W4** #237 (§3 + ADR 0089 §5) | Four amendments to one ADR, one per wave, each a different section. **#232's is a build gate: the amendment lands before any code.** | +| `docs/STEPS-PALETTE.md` | **W2** #248 · **W3** #232 · **W4** #238 (speculative) | Source-of-truth-linked to `stepsModel.ts` `ADD_MENU_CATALOG` + `lens.py` codegen — a new group updates it **in the same commit**. #238 may not need it at all. | +| `ide/package.json` | **W1** #233 (**conditional — option (a) only**) · **W2** ide-symbol-index-breadth (#228) · **W3** #232 · **W5** #236 | **The conditional W1 claim is a live risk:** if #233 takes option (a) it owns the manifest; options (b)/(c) leave it untouched. **The session must announce its choice in the 🚧 claim** so W2 knows whether the file was moved. | +| `docs/adr/0087-sandbox-subprocess-isolation.md` | **W1** #341 (amendment note) · **W2** #343 (stderr residual) · **W3** #342 (kill/reap residual) · **W4** #346 (invariant docstring anchor) | Four items, four waves. #341's note is what forced #343 out of W1. | +| `messagefoundry/pipeline/sandbox.py` · `_sandbox_worker.py` | **W2** #343 · **W3** #342 · **W4** #346 | The `Popen` at `:262-269` is edited by all three (`stderr=None` at `:266` by #343; the spawn flags by #342; a docstring by #346). **Never same-wave.** | +| `messagefoundry/pipeline/_sandbox_codec.py` | **W1** #341 (the parity table + its rationale) · **W4** #346 | #341 must **rewrite** `test_sandbox_codec.py::test_partition_parity_table`; #346 only reads the codec module. | +| `tests/test_sandbox.py` | **W2** #343 (new stderr coverage — zero assertions exist today) · **W3** #342 (grandchild-reaping test) | Two waves. | +| `messagefoundry/pipeline/dryrun.py` · `dryrun_trace.py` | **W1** dryrun-fanout-contract · **W5** steps-test-this-step | `dryrun_trace.py:122` documents itself as "the Send half of `dryrun._partition`" — a raise-on-non-list fork in W1 changes what W5's trace has to render. **W5 rebases over W1 and re-reads that docstring.** | +| `messagefoundry/config/wiring.py` | **W1** #341 (`HandlerFn` hint at `:2637`) · **W3** #333 (`expected_relaxed_hops()` + generic-ODBC reader beside `accepted_cleartext_hops`) | Different regions, different waves. | +| `messagefoundry/config/settings.py` | **W1** #328 (`IntegritySettings` @3189 only) · **W2** #343 (`SandboxSettings` @1220) · **W3** #333 (`security_loosenings()` @4062, docstring @200-208) · **W5** #329 (posture threading) | **Four owners. #326 is AT RISK here and is contained by rule: its predicate hoist stays INSIDE `__main__.py`** — hoisting it into `settings.py` would put a fifth item on the file in W1. | +| `messagefoundry/api/app.py` | **W1** #328 — **SCOPE-DROPPED, do not touch** · **W3** #333 (`:1524`) · **W4** #338 (`:1528` posture field) · **W5** #329 (posture into `create_app`) | Dropping #328's optional anchor route is a deliberate de-confliction, not a scope cut: §§1-3 of #328 are CLI + settings only. | +| `messagefoundry/api/_ui_seam.py` | **W2** #324 (prose at `:100` — **no seam bump**) · **W4** #338 (`ENGINE_UI_SEAM` 17 → 18 at `:91`) | **The bump and the prose fix must not be in the same wave** — the bump is a coordinated multi-file commit with a regenerated golden snapshot, and a stray prose edit in it is exactly how a seam mismatch ships. | +| `packaging/messagefoundry-webconsole/tests/test_webui.py` | **W2** #324 · **W4** #338 | **`pytest` `testpaths=["tests"]` SILENTLY SKIPS this package (~344 tests).** Both sessions must name both paths. | +| `tests/test_security_doc_drift.py` | **W2** #324 (`_MULTI_PERMISSION_ROUTES` @86) · **W5** #318 (`route_gates` walk it derives from) | **#324 first is the safer order and is why #318 is in W5** — #324 is gate-level and small; #318 rewrites the derivation. | +| `docs/SECURITY.md` | **W1** #326 (`:1083-1084` decision rows) + #328 (same session) · **W2** #324 (`:213`, `:474-477`) | **The owner may elect the line-disjoint treatment used for `docs/BACKLOG.md` banners (the regions are ~600 lines apart) and pull P1 #324 into W1. The default here is over-caution.** | +| `docs/CONFIGURATION.md` | **W1** #326+#328 (`:~1436` blind-spot prose) · **W2** #343 (`[sandbox]` @791-829 + index @51) · **W3** #333 | Three waves, three sections. | +| `docs/CONNECTIONS.md` | **W1** #341 (`:205` fan-out contract) · **W3** #333 (per-connection TLS) · **W5** #352 (`:743`/`:755` AV statements) | **`tests/test_asvs_file_surface_doc_drift.py` pins the file-surface/quarantine text — W5's session must run it.** | +| `docs/USER-GUIDE.md` | **W1** #341 (`:395`) · **W4** #249 (`:463-464` graph snippet) | Two waves. | +| `docs/DEPLOYMENT.md` | **W3** #333 · **W5** #329 | Two waves. | +| `docs/SECURITY-LOOSENING.md` | **W1** #326 · **W3** #333 | Two waves. | +| `docs/FEATURE-MAP.md` | **W1** #328 (`:229` CLI subcommand count — **hand-maintained, no test catches it**) · **W2** #235 (`:205`/`:229` lens surface) · **W4** #249 | Three waves. | +| `tests/test_cli.py` | **W1** #326 (arm-C + `serve_web_console=false` rows) + #328 (same session) · **W4** #249 | Two waves. | +| `messagefoundry/checks.py` | **W2** #337 (`_ambient_authority_hit` @690-721, phi-to-log @921-934) · **W3** #333 (new sibling advisories modelled on `_check_cleartext_accepted` @1317-1360) | Two waves, two different regions. **#326 does NOT touch `checks.py`** — verified, it holds no `require_mfa`/`is_loopback`/`tls_terminated_upstream` reference at all. | +| `.github/workflows/ci.yml` | **W2** #344 (`Web console tests` timeout @446-448, matrix @575-577) · **W4** #340 (`merge_group:` trigger) · **W5** #327 (`noncode` allowlist @664, session `private-paths-ignore-control`) **then** #1000 (coverage job, session `merge-gate-negative-controls`) | Four items, four owners, three waves. **The W5 pair is the second deliberate same-wave exception: two different sessions, disjoint regions (`:664` vs the coverage job), #327 merges FIRST and #1000 rebases.** | +| `.github/workflows/security.yml` | **W1** #334 (`:413` semgrep argv) · **W3** #332 (`:87-89` export lockstep) · **W4** #340 (`merge_group:`) · **W5** #1000 (7 required contexts) | Four waves, four regions. | +| `docs/CI.md` | **W2** #344 · **W4** #340 · **W5** #327 **then** #1000 (two sessions, merge-ordered, one bullet each) | `tests/test_required_contexts.py` parses its "Checks required to merge" bullet list (`:53`, `:59`, `:137-148`) — **any new required context forces this file in the same PR**. | +| `.github/required-contexts.txt` | **W4** #340 · **W5** #327 **then** #1000 (two sessions, merge-ordered) | **#340 does NOT change the count** (a `merge_group` trigger changes where a context reports, not the required set) — only #1000 can, and only with a branch-protection change first. #327's touch is the comment block only. | +| `.github/workflows/backlog-hygiene.yml` · `.github/workflows/cla.yml` | **W4** #340 (`merge_group:`) · **W5** #1000 (negative controls; **`cla` has no possible in-repo control**) | Two waves. | +| `tests/test_required_contexts.py` | **W4** #340 · **W5** #1000 | It ERRORS on a missing `_CLAIM_FILES` path (`:58-67`) — eight files, including `docs/design/freethread.md`, `manifest-lint.yml`, `zizmor.yml`, `freethread-smoke.yml`, `tests/test_push_guard.py`, `scripts/hooks/push_guard.py`. | +| `tests/test_lint_scope_parity.py` · `.semgrep/messagefoundry.yml` | **W1** #334 · **W5** #1000 | **#1000's bandit fixture-exclusion will trip #334's new parity arm** — that is the point of separating them by four waves. | +| `.pre-commit-config.yaml` | **W5** #327 **then** #1000 — two sessions, merge-ordered | **A planted scanner fixture blocks every contributor's local commit, not just CI** — gitleaks + forbidden-content run as local hooks. #327 touches it in one place; #1000 rebases over that. | +| `scripts/security/scan-allowlist.txt` | **W1** #321 (structural-shape allowlist entries) · **W5** #1000 (fixture exemptions) | **Sweep the whole tracked tree before merging either** — the #325 lesson: 47 false positives reds a required context on the first run. | +| `scripts/security/scan_forbidden.py` | **W1** #325 + #321 (same session, sole W1 owner) · **W3** #331 (`token_floor_failure()` @547 → the `leak_check` bridge) | **#334 is OFF-LIMITS here** — its repo-wide semgrep widening lands in the same wave, and this regex/`subprocess`-dense module is a prime `nosemgrep` candidate. **`leakgate-detector-blindspots` carries any annotation #334 needs in this file**; #334 never opens it. **#322 is W2 and must NOT edit this file either.** If the owner wants the per-hit reason string, #322 moves after W3. | +| `tests/test_scan_tokens_source.py` | **W1** #325 (`:559` casing fixtures) · **W2** #322 (guidance paragraph in the header region) | **`_EXAMPLE` at `:30` parses the `.example` file structurally and asserts exactly `{acme, exampleco, examplevendor}` at `:114`.** | +| `scripts/security/scan-tokens.local.txt.example` | **W1** #321 (owner-loaded token data) · **W2** #322 (guidance) | Two waves. | +| `scripts/dev/setup-leak-gate.ps1` | **W2** #322 (`-Synthetic` pointer) · **W5** #327 (`:58` — the ONLY existing `check-ignore` call in the tree) | Two waves. | +| `messagefoundry/anon/leak.py` | **W1** #321 (**read-only** cross-ref, ADR 0030 §5) · **W3** #331 (`leak_check` @50 — the real edit) | If W1's session finds itself editing it, stop: that is #331's work. | +| `tests/test_release_pipeline.py` | **W3** #332 (step markers + order) · **W5** #327 (`:38` PRIVATE_CANARY, `:117-127` groundedness) | Two waves, two regions. | +| `pyproject.toml` · `requirements.lock` | **W3** #332 (PEP 735 `release-tools` group + seventh lock) · **W5** #318 (OpenAPI overlay, "a fifth DEP-1 lock") | **Both are DEP-1 re-locks needing owner approval before any install. Never same-wave — two concurrent `uv lock` runs is a guaranteed silent divergence.** | +| `scripts/ci/check_required_workflow_state.py` | **W2** #344 (margin arm) · **W4** #340 (merge_group-reporting arm) | Two waves. **`.github/workflows/required-workflow-state.yml` moves with W4's edit** (its `paths:` trigger at `:31` already includes `required-contexts.txt`). | +| `docs/adr/0034-static-analysis-triage-policy...md` | **W1** #335 (§1 `:144-147` readability decision) · **W3** #332 (residual row `:350` sigstore-in-lock) | Two waves, two rows. | +| `tests/test_asvs_phase0.py` | **W1** #335 (16.4.1 coverage) · **W5** #329 (per-cell TLS refusals) | Two waves. | +| `docs/ASVS-L2-PHASE0-CHANGES.md` | **W1** #335 (`:47` 16.4.1 claim) · **W4** #338 (KEX inheritance) | Two waves. | + +**Non-collisions worth recording so nobody invents them.** `messagefoundry/security/semgrep/handler-security.yml` (#337, W2) is a **different file** from `.semgrep/messagefoundry.yml` (#334/#1000) — do not conflate them. `.github/workflows/dependabot-auto-merge.yml` (#336, W2) is a **different file** from `dependabot-lock-resync.yml` (#332, W3), so the two supply-chain items never touch the same YAML. `messagefoundry_webconsole/` needs **no edit for #333** — `pages/monitoring.py:492-499` iterates `posture.loosenings` generically — so #333 and the webconsole items are file-disjoint. `store/*.py` and `pipeline/wiring_runner.py` are **#214-only across all five waves** (verified against every item's map), so the "give #214 its own wave" instinct is unnecessary — its blast radius is inside `tests/`, where 37 files reference `transform_handoff`. `pipeline/cluster.py`/`cluster_sqlserver.py` are **#351-only**. `harness/` is **#320-only**. `messagefoundry/logging_setup.py` is **#335-only**. `messagefoundry/anon/` + `tee/anon/` are **#331-only**. `ide/src/aiPolicy.ts` + `engineStatusModel.ts` (#330) share **nothing** with the Steps cluster, which is why #330 sits in W1 beside #233. `ide/src/graphTree.ts`/`symbolIndex.ts`/`graphModel.ts` (#228) likewise share only `ide/package.json` with the Steps cluster. `docs/adr/README.md` is claimed by **exactly one session in this plan** (#214's new ADR row) — unusual for this repo, and worth stating so nobody assumes it is contended. `ide/src/test/suite/index.ts:13` globs `**/*.test.js`, so **new IDE test files need no registration edit** and cannot collide there. + +**Where I am NOT certain, stated plainly rather than guessed:** +1. **#1000's blast radius is not fully enumerable.** Building a negative control per gate may require touching files no map can predict — a bandit exclusion, a semgrep `paths:` stanza, a gitleaks allowlist entry. It is in W5 partly for that reason. **If it needs a file another wave already moved, rebase; do not reach backwards.** +2. **#334's widened semgrep scan may need `nosemgrep` annotations in files this plan does not list** (+56 newly-scanned files). **These files are NOT unowned — the earlier draft of this note said so and was wrong.** Real owners: `scripts/security/scan_forbidden.py` → W1 `leakgate-detector-blindspots` + W3 #331 (**off-limits to #334; that session carries the annotation**); `scripts/quality/lens_coverage.py` → W2 #248, W3 #232, W4 #237; `scripts/security/route_gates.py` · `dast_auth_sweep.py` · `dast_target.py` → W5 #318; `scripts/security/crypto_inventory_check.py` → W5 #1000; `scripts/ci/check_required_workflow_state.py` → W2 #344 + W4 #340; `scripts/ci/check_stalled_prs.py` → W4 #340; `messagefoundry_webconsole/**` → W2 #324 + W4 #338. **#334 may annotate only files with no wave owner, and must announce every such edit in the PR body**; anything owned goes to the owning session. `docker/` is genuinely unowned. +3. **#233's `ide/package.json`/`esbuild.js`/`tsconfig.json` claim is conditional on option (a).** Until the owner picks, W1 must be treated as holding those files. +4. **#341's `lens.py` touch is scoped as a comment review.** If the accumulator recognizer needs a logic change, it collides with W2/W3/W4 and must move. +5. **#336's `.github/dependabot.yml` may need no edit at all** now that the cooldown has shipped. It is kept on the map so a scheduler treats it as contended. +6. **#322's `scan_forbidden.py` status.** Listed read-only; if the owner wants the per-hit reason string, it becomes a real edit and collides with #331. + +--- + +## D. Coordination rules & gotchas + +- **One worktree / branch / `.venv` per session:** `scripts/worktree/new.ps1 -Name plan-` ([docs/WORKTREES.md](../WORKTREES.md)), branched off **`origin/main` @ `1cf04732`** (`git fetch` first — this worktree's HEAD is `857a70d4`). **Never branch off a sibling session's branch**, even when its work looks like a prerequisite: the only hard dependency in this plan (#233 → #237) is satisfied by #233 being **merged to `origin/main`**, not by branching from it. Re-check in-flight ownership before starting: `git worktree list`, `gh pr list --state all`, `git log origin/main`. +- **Every session updates its own item's BANNER in `docs/BACKLOG.md` — and nothing else in that file.** Write the 🚧 in-progress claim naming the lane **before** writing code, in its own commit; flip to ✅/⛔/🪦 when the work lands. Edits are **line-disjoint per banner** (positions in §C) and the **owner serialises the merges within a wave**. **One `> ` banner per item; CLOSED must never coexist with OPEN.** The 🚧 claim is the *only* signal that stops a sibling worktree double-building the same item — neither the worktree gate nor the ledger gate catches duplicated work. + +- **RULE 1 — the live ranked table (`docs/BACKLOG.md:180-272`) and the Distribution census (`:169-171`) are OWNER-ONLY. No session touches either, in any wave, for any reason.** The table holds **93** rows — one per open item, contiguous and one line each — and the plan's 41 schedulable items are **interleaved among them**, so wave-mates land inside git's 3-line merge context (W1 alone: `:180`/`:182`, `:191`/`:192`, `:208`/`:209`, `:233`/`:234`); the census is a single 4-line hunk every session would rewrite, and two sessions each re-deriving it from a tree the other already decremented **merge clean and publish a wrong count**. Both surfaces are *derived*: `:165` states it outright — **"the banner is the live record. This table is a view of it, and where the two disagree the banner wins."** So sessions move the record, and **the owner runs ONE reconcile pass per wave, after that wave's merges**, re-deriving the affected rows and recomputing all four census lines in a single commit. A session that believes its row is now false says so **in its PR body**; it does not edit the row. +- **RULE 2 — `tests/test_cutover_slug_rot.py` is OWNER-ONLY, and the risk is not confined to doc-heavy sessions.** It is a **two-way** ratchet over `git ls-files`: a hard ceiling (`_PROSE_CEILING = 54` at `:84`, asserted at `:175`) **and** a slack check at `:197-200` that fails when the ceiling has unused slots. Prose **removal** trips it exactly as prose addition does. **Any session whose diff removes a `the mirror` / `public mirror` / `private repo` phrase** — not just #64, #238, #352, #327 — can force a ceiling change. Two sessions adjusting it in parallel collide silently. Report the required value in the PR body; **the owner makes the edit, in the same per-wave pass as RULE 1.** + +- **Backlog and ADR numbers come from `pwsh -NoProfile -File scripts\coord\alloc.ps1 -Kind adr -Title ""` — NEVER from a grep.** Two sessions that both grep pick the same number, create differently-named files, **merge clean**, and silently corrupt the ledger. It has fired three times. **The `pre-commit` ledger gate rejects a number you did not allocate, and it must never be bypassed with `--no-verify` or a rename workaround** ([docs/LEDGER-GATE.md](../LEDGER-GATE.md)). The ADR's index row in `docs/adr/README.md` lands in the **same commit** as the ADR. +- **Only ONE session in this plan allocates a number: `store-batched-transform-handoff` (#214).** Every other ADR touch is an **amendment to an existing, already-numbered ADR** — 0034 (#335, #332), 0035 (#330), 0072 (#236), 0076 (#232/#234/#235/#237/#248), 0087 (#341/#342/#343/#346), 0089 (#237), 0092/0094/0118 (#329/#333 — *no new ADR*), 0144 (#337), 0155 (#318), 0159 (#351), 0030 (#331), 0045 D1 (#324, only under option 2). **Amendments allocate nothing.** +- **Baseline the banners against the code, not against `origin/main`'s BACKLOG.** `origin/main`'s copy is a frozen publish snapshot, not the ledger — write banners **fresh** from what the session actually verified. +- **`pytest` `testpaths=["tests"]` silently skips `packaging/messagefoundry-webconsole/tests/` (~344 console tests).** Sessions touching the console (#324, #338) must name **both** paths. Never infer absence from a scoped grep. +- **The SQL Server and Postgres store legs SKIP SILENTLY under local pytest.** #214 and #351 are unprovable on a dev box (`#351`'s module is `pytestmark skipif` on `MEFOR_TEST_SQLSERVER`). A locally-green run proves nothing; repro with real containers or gate on the CI legs. **A native SQL Server steals the port from Docker** — the container looks healthy while pyodbc hits the wrong server. +- **Windows CI is the authority for four items.** #342 (job object) and #343 (pipe/reader-thread) are platform-asymmetric — **a green Linux leg proves nothing about the Windows half and vice versa**. #344 sizes the `windows-2022`/`windows-2025` bounds; #320's probe matrix is Windows-only. #344's margin script reads the GitHub Actions API and can only be validated in CI. +- **The IDE leg is npm/mocha, not pytest, and it is NOT a required check while auto-merge is on.** #330, #228, #233, #232, #235, #236, #237, #248 all run `cd ide && npm run typecheck && npm run compile && npm run test:unit`. **Put a manual hold on any IDE PR until that leg is green** — auto-merge will otherwise land it red. The `npm-audit` required context does watch `ide/` dependency changes. +- **Never quote the required-context count from memory** — it has moved six times in a day. Read `.github/required-contexts.txt` or the API. (It is 13 at `1cf04732`, and #340 must **not** change it.) +- **Do not idle waiting for CI.** The suite is ~15 minutes; push and merge in the background. +- **No `Co-Authored-By: Claude` trailer** — the CLA bot fails on it. +- **Verify order (engine sessions):** `ruff check` + `ruff format --check` → `mypy messagefoundry` (strict) → `$env:QT_QPA_PLATFORM='offscreen'; pytest -q`. A green local quartet is **not** a green CI: some guards are CI-only, and the leak gate **fails closed** without a token source. +- **PHI and invariants.** #341 is an accept-and-drop fix — whichever fork wins, **no message may finalize `FILTERED` when the Handler returned deliveries**. #324 is a live PHI-read hole. #347 must be **falsified red-first** before its green is trusted. #346's import walker must be falsified against a deliberately introduced forbidden import — **an import-walker that resolves nothing goes green for the same reason a correct one does**. #236's state dump must persist nothing and add no second gate. None of these sessions touches the stage handoff or the ACK contract **except #214**, which touches nothing else; if any other session's scope drifts there, stop and re-plan. +- **W5's two CI sessions merge in order: `private-paths-ignore-control` (#327) FIRST, then `merge-gate-negative-controls` (#1000) rebases over it.** They share exactly four files (`ci.yml`, `required-contexts.txt`, `.pre-commit-config.yaml`, `docs/CI.md`), #327 touches each in one place, and this ordering is what keeps the same-wave exception safe. + +**Phantom and stale references found during mapping — do NOT schedule work against these:** + +| Item | Reference | Reality | +|---|---|---| +| #333 | `adr/0094-tls-expiry-relaxation.md`, `adr/0118-security-loosening-warning-and-posture-view.md` (BACKLOG.md:3242) | **Neither exists.** The real files are `0094-granular-expiry-only-tls-relaxation.md` and `0118-secure-by-default-security-configuration-section.md`. The ADRs are real; only the link targets are dead, and they appear **nowhere else in the tree** — fix them in the same BACKLOG edit. Does **not** make the item stale. | +| #337 | `docs/SECURING-HANDLER-CONFIG-IN-CI.md` | **Absent from the tree**, yet linked as a bare filename from `docs/ADOPTER-CI.md:178`. Dangling reference. **Creating it would be new scope, not this item.** | +| #338 | `docs/BACKLOG.md:6416`/`:6422` (#200's *Closes* line) | **Stale.** `docs/BACKLOG.md` is 3,858 lines and contains no item #200. The real target is `docs/archive/backlog/BACKLOG-CLOSED.md:4293` under the heading at `:4287`. | +| #228 | a ported-feed config module named in the item's prose | Not in this repository — it is an illustrative reference to a feed in the maintainer-internal migration repo. **No work is scheduled against it**, and the path is deliberately not reproduced here. | +| #249 | `lens graph` subcommand | **Does not exist** — `lens` has only parse/rewrite. Title-vs-body mismatch; the body correctly targets top-level `graph --json`. Confirm the surface with the owner. | +| #318 | BACKLOG #89's "ADR 0054 adversarial audit harness" | **Does not exist.** There is no mutator to extend for protocol fuzzing. | +| #342 | `docs/security/THREAT-MODEL.md` (pinned by `test_threat_model_doc_drift.py:762`) | **Vault-only** — `docs/security/` is gitignored and absent here, so the test cannot run in this checkout. The description string may need a vault-side update **the owner makes separately**. | +| #344 | root `HANDOFF-344-instance-2.md` | **Stale** — it describes instance 2 as unpushed; that work is already merged at HEAD. Delete or correct it in the #344 PR. | +| #327 / #336 / #320 | line anchors | Drift, not phantoms: `ci.yml:472`/`:658`→`:664`; four `dependabot-auto-merge.yml` citations moved; `report.py`'s stranding comment was already rewritten. **Re-measure before quoting.** **#341 is deliberately NOT in this row** — an earlier draft listed `dryrun.py:112`→`:110` here and that "correction" was itself false; the item's `:112` is correct. See *Adversarial review*. | + +**Owner sign-off required before dispatch (not merely before merge):** #341 (contract fork) · #326 (§3 refuse-on-upgrade fork) · #324 (Proposed 1 vs 2) · #214 (owner-deferred + ULTRACODE) · #235 (schema-emission fork) · #237 (re-price demanded by the 2026-08-03 amendment) · #249 (**verdict is "owner decision pending — proposed, not approved"**) · #336 (changes what merges to `main` unattended) · #332 (release pipeline) · #340 (branch protection) · #1000 (any new blocking context, **plus the split-by-gate-family call**) · #318 (increment-2 scope + a new dependency) · #351 (triage needs #348's author) · #352 (5.4.3 is CLOSED by owner decision; needs an external human) · #321 (token data is owner-run, in **both** the Actions and Dependabot secret stores — miss Dependabot and every Dependabot PR hard-fails the required check) · #233 (option a/b/c) · #329 (SFTP TOFU question surfaced, not settled). + +--- + +## E. Coverage appendix + +All **41** items are placed. **None is dropped.** V/D and tier are read from each item's banner at HEAD. + +| Item | V/D | Tier | Session | Wave | +|---|---|---|---|---| +| **#341** (P1) | 9/3 | quick win | `dryrun-fanout-contract` | 1 | +| **#326** (P1) | 6/3 | quick win | `cli-exposure-and-audit-anchor` | 1 | +| #328 | 6/3 | quick win | `cli-exposure-and-audit-anchor` | 1 | +| #233 | 6/3 | quick win | `steps-dropmodel-and-save-refresh` | 1 | +| #234 | 4/3 | fill-in | `steps-dropmodel-and-save-refresh` | 1 | +| #334 | 5/2 | fill-in | `semgrep-scope-widening` | 1 | +| **#321** (P1) | 7/3 | quick win | `leakgate-detector-blindspots` | 1 | +| #325 | 6/2 | quick win | `leakgate-detector-blindspots` | 1 | +| #347 | 5/2 | fill-in | `phi-at-rest-assertion-strength` | 1 | +| #335 | 4/3 | fill-in | `logging-controlchar-escapes` | 1 | +| #330 | 5/3 | fill-in | `ide-aiassist-gate` | 1 | +| #248 | 6/4 | quick win | `steps-note-rows-and-param-schema` | 2 | +| #235 | 4/4 | fill-in | `steps-note-rows-and-param-schema` | 2 | +| **#324** (P1) | 7/2 | quick win | `ui-editor-phi-gate` | 2 | +| #343 | 4/3 | fill-in | `sandbox-stderr-relay` | 2 | +| #344 | 6/3 | quick win | `ci-step-margin-bounds` | 2 | +| #336 | 5/3 | fill-in | `dependabot-automerge-allowset` | 2 | +| #322 | 2/1 | fill-in | `leakgate-placeholder-guidance` | 2 | +| #337 | 3/3 | fill-in | `handler-security-lint-recall` | 2 | +| #228 | 4/2 | fill-in | `ide-symbol-index-breadth` | 2 | +| #320 | 3/3 | fill-in | `ingress-stranding-under-load` | 2 | +| #214 | 3/8 | money pit | `store-batched-transform-handoff` | 2 | +| #232 | 5/5 | fill-in | `steps-router-rows` | 3 | +| #333 | 6/4 | quick win | `tls-loosening-registry-visibility` | 3 | +| #332 | 6/5 | quick win | `release-signing-hashed-locks` | 3 | +| #342 | 5/6 | money pit | `sandbox-process-tree-reap` | 3 | +| #331 | 6/4 | quick win | `anon-structural-detectors` | 3 | +| #351 | 4/3 | fill-in | `cluster-failover-margin-triage` | 3 | +| #237 | 4/4 | fill-in | `steps-per-argument-modes` | 4 | +| #338 | 3/2 | fill-in | `tls-kex-seam-readout` | 4 | +| #340 | 6/4 | quick win | `ci-merge-queue-preconditions` | 4 | +| #346 | 4/3 | fill-in | `sandbox-import-boundary` | 4 | +| #249 | 3/2 | fill-in | `graph-export-formats` | 4 | +| #238 | 1/1 | fill-in | `openflow-attribute-completeness` | 4 | +| **#1000** (P1) | 7/3 | quick win | `merge-gate-negative-controls` | 5 | +| #327 | 6/2 | quick win | `private-paths-ignore-control` | 5 | +| #318 | 7/6 | big bet | `dast-increment-2` | 5 | +| #236 | 5/4 | fill-in | `steps-test-this-step` | 5 | +| #329 | 6/4 | quick win | `insecure-tls-clamp-cells` | 5 | +| #352 | 1/1 | fill-in | `av-consult-prep` | 5 | +| #64 | 1/1 | fill-in | `throughput-roadmap-reconcile` | 5 | + +**Count: 41 items → 37 sessions → 5 waves → 60.0 build-days.** Rows above: 11 (W1) + 11 (W2) + 6 (W3) + 6 (W4) + 7 (W5) = **41**. Wave totals: W1 11.0 (8 sessions) · W2 17.0 (10) · W3 12.0 (6) · W4 **7.5** (6) · W5 12.5 (7) — **sum 60.0**. + +**Items deliberately scheduled but NOT expected to close in their session (say so in the banner, do not flip ✅):** +- **#352** — a session can only draft the four questions and stage the recording slot; the answer comes from an external human. Banner stays 🚧. +- **#320** — diagnosis-only; the decisive run needs the self-hosted WS2025 rig (never run, runner unregistered). Only the hosted contention arm lands. Banner stays 🚧. +- **#318** — increment 2 is several sub-projects; the session lands whichever slice the owner scopes (the nightly-notice widening is the clean 0.5-day carve-off). Banner stays 🚧. +- **#1000** — **3 days is a cap, not an estimate.** The `cla` context has no possible in-repo negative control; the deliverable includes **publishing that gap**, not papering over it. The split-by-gate-family recommendation stands and is now cheap to take, because #327 is a separate session. +- **#340** — a session can deliver only step 2 (the `merge_group:` triggers); steps 1 and 3 are server-side branch-protection changes. Banner records the split. +- **#321** — the token-data half is owner-run in two secret stores; the session lands the structural backstop only. +- **#249** — **do not dispatch at all until the owner converts "proposed, not approved" into a go.** + +**Scope explicitly excluded inside scheduled items (recorded so nobody re-adds it):** #214 residual (b) `transform_concurrency` setting — **DECLINED**. #344 proposal 3 virtual-clock deadline — **WITHDRAWN**; instance 2 — **already merged**. #336 §2 github-actions cooldown — **already shipped**. #321's detector-coverage half of Proposed 2 — **already exists**. #248's Amendment B helper descent — **DECLINED**; comment re-attachment on move/delete, end-of-body parent nesting (§A.6), and grouping/collapse (#231) — out of scope. #337's non-recursive glob (`checks.py:893`/`:898`) — belongs to #226. #338's Python-3.15 tripwire tests — **left red by design**. #329's `transports/database.py` unstamped fallback — excluded by the item. #232's `messagefoundry/actions.py` — no entry implied. #64's `THROUGHPUT-IMPROVEMENTS.md` bodies — editing them would contradict their own superseded banner. **The `docs/BACKLOG.md` ranked table, the Distribution census, and `tests/test_cutover_slug_rot.py` — owner-only, excluded from every session (§D RULES 1-2).** + +--- + +## Adversarial review + +An independent adversarial pass over the authored plan (2026-08-03, against HEAD `857a70d4`) ran six checks: same-wave file collisions, over-grouping, missed dependencies, dependency inversion, dropped items, and false repo claims. **Checks 2-5 passed outright** — every paired session shares real files, the sole hard dependency (#233 → #237) is genuine and correctly ordered, no item sits before a dependency (the TLS, seam, DAST, scanner and sandbox clusters were each walked), and the coverage appendix accounts for all 41 items with no drop or double-placement. Six defects were found and **all six are absorbed above**: + +1. **SEVERE — a second `docs/BACKLOG.md` edit surface was never mapped.** §C de-conflicted only banner regions; every item also owns one line of the **live ranked table (`:180-272`, contiguous, no gaps)** and a share of the **4-line Distribution census (`:169-171`)**. Thirteen same-wave, different-session pairs sat inside git's 3-line merge context — six on literally adjacent lines (W1: `:180`/`:182`, `:191`/`:192`, `:208`/`:209`, `:233`/`:234`). The census is worse: one hunk every session rewrites, where two independent re-derivations **merge clean and publish a wrong count**. **Changed:** both surfaces are now **owner-only** with a per-wave reconcile pass — §C gained a row, §D gained **RULE 1**, and #64's session lost its ranked-table claim. Justified from the file's own line at `:165`: the banner is the live record and the table is a view of it. +2. **SEVERE — the "+56 newly-scanned files are all currently unowned" note was false, and the overlap was in Wave 1.** `scripts/security/scan_forbidden.py` is rewritten by W1's `leakgate-detector-blindspots` and is a prime `nosemgrep` candidate for #334's blocking widened scan; `messagefoundry_webconsole/`, `lens_coverage.py`, the DAST scripts and the CI scripts all have wave owners too. **Changed:** fix (a) taken — the file is **off-limits to #334**, and `leakgate-detector-blindspots` carries any annotation it needs. §C's uncertainty note 2 now names every real owner instead of asserting "unowned". +3. **HIGH — the plan had introduced a false line-number correction, and presented it as verified.** It told the P1 accept-and-drop session that `dryrun.py:112` had drifted to `:110`. **`:112` is correct** — it is the narrowing `items = result if isinstance(result, list) else [result]`; `:110` is the docstring's closing line and `_partition` is defined at `:102`. A wrong "verified correction" is worse than no correction, because it is the form most likely to be trusted without re-measurement, and the plan's own *Re-measure before quoting* rule was not applied to it. **Changed:** the note is struck from the §A row, from the Start-here row, and from §D's drift table — and the error is recorded here rather than quietly deleted, because a plan that hides its own corrected mistakes teaches nothing. +4. **MEDIUM — the wave arithmetic did not close.** W4 is 3 + 1 + 1 + 1 + 1 + 0.5 = **7.5 days**, not 8.5; the stated wave totals summed to 61.0 against a correct headline of 60.0. **Changed:** W4 = 7.5 everywhere (§A total, §B wave heading, §E); totals now read 11.0 / 17.0 / 12.0 / 7.5 / 12.5 = **60.0**. +5. **MEDIUM — `merge-gate-negative-controls` contradicted itself**, recommending #1000 be split before dispatch while merging it with #327 into one 4-day, 20-file session. **Changed:** #327 is now its own W5 session, `private-paths-ignore-control` (1 day), and the split recommendation for #1000 **stands**. #1000's **3 days is a cap, not an estimate**, stated plainly in §A, the Start-here note and §E. The two sessions share exactly four files, each touched by #327 in one place; that share is declared the plan's **second deliberate same-wave exception**, with #327 merging first and #1000 rebasing. Session count 36 → **37**; W5 6 → **7**; effort unchanged (3 + 1 = the former 4). +6. **LOW — `tests/test_cutover_slug_rot.py` was named as a risk but owned by nobody, and the risk was understated.** It is a **two-way** ratchet — the slack check at `:197-200` fails on *unused* ceiling slots — so prose **removal** trips it as readily as addition. **Changed:** added to §C as owner-only, and §D's warning widened from "doc-heavy sessions" to **any session whose diff removes a mirror/public-repo phrase**, with the ceiling change routed through the owner in the same per-wave pass as RULE 1. + +**Unchanged because the review confirmed them.** The one-owner-per-wave map holds on every file the plan lists (`__main__.py`, `lens.py`, the Steps quintet, ADR 0076/0087, `sandbox.py`, `settings.py`, `api/app.py`, `security.yml`, `ci.yml`, `required-contexts.txt`, `scan_forbidden.py`, the lock files, and the rest); all five pairings are real; the dependency ordering is correct throughout; and the repo claims re-verified at HEAD stand — 21 workflow files with **no** `merge_group`, **13** required contexts pinned at `test_required_contexts.py:101`, `ENGINE_UI_SEAM = 17` against `SUPPORTED_ENGINE_SEAMS = frozenset({17})`, zero gate references in `checks.py`, exactly three production callers of `security_loosenings()`, the `insecure_tls_allowed()` census (four in-scope cells + the excluded `database.py:112`, with `direct.py`/`email.py`/`mllp.py` prose-only), `_BYTE_IDENTICAL` in `test_anon_parity.py:24`, `_MULTI_PERMISSION_ROUTES` at `test_security_doc_drift.py:86`, all three #347 anchors, `test_lint_scope_parity.py` containing zero occurrences of "semgrep", `security.yml:413`, `ci.yml:664`, the two dead ADR links, `editor-toolbar.test.ts:62`, `lens.py:305`, the `test_partition_parity_table` rows, the `scan_forbidden.py` regex anchors, the stale `HANDOFF-344-instance-2.md`, and the live-introspecting `webconsole_seam.snapshot`. +### Second round — what re-attacking the revision found + +The revised plan was attacked again, because a fix is a change and a change can regress. All eight +regression checks passed (the ranked table and census are owner-only in both §C and §D and no Owns +list claims a row; `scan_forbidden.py` is gone from #334's scope; no `:110` instruction survives; the +wave efforts sum to 60.0 with W4 at 7.5; #327 stands alone; `test_cutover_slug_rot.py` is owner-only; +41 items, none dropped or double-placed). It then found **seven further defects, all now applied**: + +1. **HIGH — fix 2 created an undeclared same-wave merge order.** Taking `scan_forbidden.py` away from + #334 moved the `nosemgrep` it may need into a sibling W1 session, so #334's blocking gate could red + with no remedy inside the session that owns the failure. W1 is therefore **not order-free**: + `leakgate-detector-blindspots` merges first. *A de-confliction that silently creates a dependency is + only half a fix.* +2. **MEDIUM — the "hand it to the owning session" rule is temporally unsatisfiable for #334.** Every + other owner of a newly-scanned file is in a **later** wave than W1. The escape hatch is a per-rule + `paths:` exclude in `.semgrep/messagefoundry.yml`, recorded in the PR body. +3. **MEDIUM-LOW — the owner-only region was one line short.** The table runs `:180-272`, not `:180-271`; + at `:271` the last scheduled item's row (#352 @ `:272`) sat **outside** the protection. +4. **LOW — "41 ranked rows" was wrong.** The table holds **93** rows, one per open item; the plan's 41 + are interleaved among them. The adjacency conclusion is unchanged — but this was exactly the class of + "verified" structural claim the plan tells sessions to trust, produced by the pass that added it. +5. **LOW — the licensing quote is at `:165`**, not `:164` (`:164` is a bare `>`). +6. **LOW — the corrected #341 note carried its own off-by-one:** `:109` closes the docstring, `:110` is + `if result is None:`. The load-bearing anchors (`:102`, `:112`) were right. +7. **LOW — the banner-spacing sentence contradicted itself** (16–18 called an exception to a ≥14 rule) + and asserted a false 30-line W1 claim. Restated from measurement. + +**Why this section exists.** Three of the four line-number defects across both rounds were introduced by +a *verification* pass — including a false correction to a correct citation on the highest-value item in +the plan. Re-measuring is not optional because the number came from a careful reader; it is required +*because* it did. Every anchor in this document was measured against HEAD, and the ones that moved are +named above rather than quietly fixed.