From 6992f083a5a36d006af47469926a7d08a729cf88 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 09:43:29 -0500 Subject: [PATCH 01/28] fix(apiclient): measure the resolved URL, query included (BACKLOG #1047) `_request` measured `len(base_url) + len(path)` against MAX_REQUEST_URL_LEN and then handed `params=` to httpx, which appends the query AFTER that check. Every read the console/harness/tray makes goes through `_get`, so a long filter value (a search needle, a control id) would, on first deployment, build an over-long request line with nothing refusing it -- the residual under the ASVS 4.2.5 `partial`. Build the request first (httpx's own resolution step) and measure str(request.url), then dispatch the built request through `send` -- which is what `request()` does internally, so the auth and follow-redirects client defaults are unchanged. The new bound is proved by a tripwire transport rather than a stub response: the claim is that the request never reaches the wire, and a stub 200 cannot tell that apart from a request that went out. Watched RED against the pre-fix code (the 9046-char URL reached the transport). A positive control pins that a short query still goes out, so a bound that refused every query-bearing GET would not pass. test_request_maps_non_2xx_to_apierror moves its stub from `_http.request` to `_http.send` -- the same transport seam, one call deeper. --- messagefoundry/apiclient/client.py | 17 +++++++--- tests/test_apiclient.py | 54 +++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/messagefoundry/apiclient/client.py b/messagefoundry/apiclient/client.py index bb06a032..7db76d34 100644 --- a/messagefoundry/apiclient/client.py +++ b/messagefoundry/apiclient/client.py @@ -287,10 +287,19 @@ def _request( # engine-free (a GUI/harness process must not pull transports/ in), so the import that would # share them is exactly the coupling this package exists to avoid. Kept in step by # ``test_apiclient_length_bounds_match_the_transport_constants``. - if len(self.base_url) + len(path) > MAX_REQUEST_URL_LEN: + # + # The request is BUILT first so the bound measures the URL httpx will actually put on the + # wire — base_url joined to the path AND the ``params=`` query appended (BACKLOG #1047). + # Measuring ``base_url + path`` missed the query entirely, so every ``_get`` filter (a + # search needle, a control id) was unmeasured; ``build_request`` is httpx's own resolution + # step, so this asks the same question the transport will answer. ``send`` then dispatches + # the already-built request, which is exactly what ``request()`` does internally — the auth + # and follow-redirects client defaults are unchanged. + request = self._http.build_request(method, path, headers=headers, **kw) # type: ignore[arg-type] + resolved_url = str(request.url) + if len(resolved_url) > MAX_REQUEST_URL_LEN: raise ApiError( - f"request URL is {len(self.base_url) + len(path)} chars, over the " - f"{MAX_REQUEST_URL_LEN}-char limit" + f"request URL is {len(resolved_url)} chars, over the {MAX_REQUEST_URL_LEN}-char limit" ) if headers is not None and len(headers["Authorization"]) > MAX_REQUEST_HEADER_VALUE_LEN: # Never echo the value: it is a live session bearer. @@ -299,7 +308,7 @@ def _request( f"the {MAX_REQUEST_HEADER_VALUE_LEN}-char limit" ) try: - response = self._http.request(method, path, headers=headers, **kw) # type: ignore[arg-type] + response = self._http.send(request) except httpx.HTTPError as exc: raise ApiError(f"could not reach engine at {self.base_url}: {exc}") from exc # Second factor (WP-14, ASVS 6.3.3): the engine refuses a sensitive op with 403 + diff --git a/tests/test_apiclient.py b/tests/test_apiclient.py index a6e76e34..c44b19ef 100644 --- a/tests/test_apiclient.py +++ b/tests/test_apiclient.py @@ -71,7 +71,9 @@ class _Resp: def json(self) -> dict[str, object]: return {"detail": "kaboom"} - monkeypatch.setattr(client._http, "request", lambda *a, **k: _Resp()) + # `_request` builds the request (so the #1047 length bound can measure the RESOLVED url) and + # dispatches it through `send`, so `send` is the transport seam a stub replaces. + monkeypatch.setattr(client._http, "send", lambda *a, **k: _Resp()) with pytest.raises(ApiError) as excinfo: client.health() assert excinfo.value.status == 500 @@ -124,3 +126,53 @@ def test_apiclient_refuses_an_over_length_request_path() -> None: client = EngineClient("http://127.0.0.1:8765") with pytest.raises(ApiError, match="over the 8192-char limit"): client._request("GET", "/messages?q=" + "a" * 9000) + + +def test_apiclient_measures_the_query_string_httpx_appends( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """BACKLOG #1047: the bound must measure the URL httpx actually sends, not the one the caller + typed. Every read the console/harness/tray makes goes through ``_get``, which hands its filters + to httpx as ``params=`` — appended to the URL AFTER any length measured from ``base_url`` and + ``path`` alone. A long filter value (a search needle, a control id) therefore built an over-long + request line with nothing refusing it. + + The transport is replaced by a tripwire rather than a stub response: the claim is that the + request is refused *before* it reaches the wire, and a stub 200 could not tell that apart from a + request that went out and came back. ``httpx.Client.request`` dispatches through + ``self.send``, so this one patch covers both the pre-fix (``_http.request``) and post-fix + (``build_request`` + ``_http.send``) call shapes. + + Mutation: measure ``len(self.base_url) + len(path)`` again. Red: AssertionError from the + tripwire — the over-long request reached the transport.""" + from messagefoundry.apiclient.client import ApiError, EngineClient + + client = EngineClient("http://127.0.0.1:8765") + + def _tripwire(*args: object, **kwargs: object) -> object: + raise AssertionError("an over-length request reached the transport") + + monkeypatch.setattr(client._http, "send", _tripwire) + # base_url + path is 33 chars; the query httpx appends is what breaches the limit. + with pytest.raises(ApiError, match="over the 8192-char limit"): + client._get("/messages", control_id="a" * 9000) + + +def test_apiclient_still_sends_a_query_that_fits(monkeypatch: pytest.MonkeyPatch) -> None: + """Positive control for the test above: the same call shape with a short query MUST reach the + transport. Without this, a bound that refused every query-bearing GET would look identical to a + correct one.""" + from messagefoundry.apiclient.client import EngineClient + + client = EngineClient("http://127.0.0.1:8765") + sent: list[str] = [] + + def _capture(request: httpx.Request, *args: object, **kwargs: object) -> httpx.Response: + sent.append(str(request.url)) + return httpx.Response(200, json={}, request=request) + + monkeypatch.setattr(client._http, "send", _capture) + client._get("/messages", control_id="MSG1") + assert sent == ["http://127.0.0.1:8765/messages?control_id=MSG1"], ( + "the resolved URL (query included) is what the bound measures, so it is what must go out" + ) From 17dd58f783077bdabe047c7ef770b03822876f89 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 09:44:41 -0500 Subject: [PATCH 02/28] fix(worktree): give remove.ps1 a repo-root override so it can be execution-tested (BACKLOG #1037) remove.ps1 derived its repo root from $PSScriptRoot alone, so the only repository a test could point it at was the real checkout -- which no test may drive, because the script force-removes worktrees and force-deletes refs. Its branch-delete path was therefore covered by review only, and it is the one place in scripts/worktree/ where being wrong loses commits reachable from no ref and no reflog. Adds -RepoRoot, the same parameter and the same reason prune-merged.ps1 already had: default to the script own checkout, refuse a root that does not exist, otherwise resolve it. Behaviour on the default path is unchanged. tests/test_worktree_remove.py drives the real script as a subprocess against a synthetic repo under tmp_path, never against this checkout. It covers both halves of the lossless-delete discipline -- the branch that IS contained in origin/main and is force-deleted after re-verification, and the branch holding unique commits that is KEPT with its count reported -- plus the branch being read from git rather than from the directory name, the detached-HEAD refusal, the untracked/.venv case, and the $PSScriptRoot default itself (exercised against a copy inside the fixture). Evidence, not assertion. The suite was run RED first: 9 of 10 failed with "A parameter cannot be found that matches parameter name RepoRoot". Two mutations then proved the assertions discriminate rather than merely survive: branch -d -> branch -D (drop the re-verification) test_delete_branch_keeps_a_branch_holding_commits_not_on_origin_main FAILED -- the branch holding unique commits was destroyed. $branch = git rev-parse HEAD -> $branch = $Name (name the directory, not the ref) test_delete_branch_reads_the_branch_from_git_not_the_directory_name FAILED test_detached_head_refuses_delete_branch_without_removing_anything FAILED Both mutations were reverted; 10 passed. Note for a later item, NOT fixed here: the git status --porcelain at the top of remove.ps1 does not check its exit code, so an unreadable status is indistinguishable from a clean tree and the script proceeds to --force. prune-merged.ps1 closed exactly that fail-open in its own Test-WorktreeClean. Out of scope for #1037. --- docs/WORKTREES.md | 6 + scripts/worktree/remove.ps1 | 18 +- tests/test_worktree_remove.py | 301 ++++++++++++++++++++++++++++++++++ 3 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 tests/test_worktree_remove.py diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index 2e40ef98..cf105fa5 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -65,6 +65,12 @@ the two differ. It deletes losslessly: `git branch -d` first, and the forceful ` re-verifying at that moment that the branch has nothing beyond `origin/main`. A branch holding unmerged commits is **kept** and named, with its tip printed so you can act on it deliberately. +`-RepoRoot ` points the script at a checkout other than its own. It exists so the most +destructive script in this directory can be **execution-tested** +([`tests/test_worktree_remove.py`](../tests/test_worktree_remove.py) drives it against a synthetic +repo); without it the only repository a test could reach was this one, so the branch-delete path was +covered by review alone. (BACKLOG #1037.) + ## Prune the finished ones — `prune-merged.ps1` Worktrees pile up. [`prune-merged.ps1`](../scripts/worktree/prune-merged.ps1) sweeps the finished diff --git a/scripts/worktree/remove.ps1 b/scripts/worktree/remove.ps1 index 0a659701..a41b6ca0 100644 --- a/scripts/worktree/remove.ps1 +++ b/scripts/worktree/remove.ps1 @@ -12,6 +12,13 @@ actually has checked out, read from git -- not a branch named after the directory. Since new.ps1 gained -Branch the two can differ. + -RepoRoot exists so this script can be EXECUTION-TESTED. It used to derive its root from + $PSScriptRoot alone, so the only repository a test could point it at was the real checkout -- + which no test may drive, because this script force-removes worktrees and force-deletes refs. The + branch-delete path was therefore covered by review only, and it is the one place in + scripts/worktree/ where being wrong loses commits reachable from no ref and no reflog. Same + parameter, same reason, as prune-merged.ps1. See tests/test_worktree_remove.py. + .EXAMPLE .\remove.ps1 -Name alerts .\remove.ps1 -Name alerts -DeleteBranch @@ -26,12 +33,19 @@ param( [ValidatePattern('\A[A-Za-z0-9._-]+\z')] [string]$Name, [switch]$Force, # remove even with uncommitted tracked changes - [switch]$DeleteBranch # also delete the local branch + [switch]$DeleteBranch, # also delete the local branch + # Repo to operate on. Defaults to this script's own checkout -- which is what makes an absolute- + # path invocation from ANY cwd resolve the checkout that owns the worktree. Tests point it at a + # fixture so the real logic is what gets exercised. + [string]$RepoRoot ) $ErrorActionPreference = "Stop" -$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +if (-not $RepoRoot) { $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path } +elseif (-not (Test-Path -LiteralPath $RepoRoot)) { throw "RepoRoot does not exist: $RepoRoot" } +else { $RepoRoot = (Resolve-Path -LiteralPath $RepoRoot).Path } + $Parent = Split-Path $RepoRoot -Parent $RepoName = Split-Path $RepoRoot -Leaf $WorktreePath = Join-Path $Parent "$RepoName-$Name" diff --git a/tests/test_worktree_remove.py b/tests/test_worktree_remove.py new file mode 100644 index 00000000..a9cb7c86 --- /dev/null +++ b/tests/test_worktree_remove.py @@ -0,0 +1,301 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""Execution tests for ``scripts/worktree/remove.ps1`` (BACKLOG #1037). + +``remove.ps1`` is the most destructive script in ``scripts/worktree/``: it force-removes a worktree +and, under ``-DeleteBranch``, force-deletes a ref. ``git worktree remove`` destroys the per-worktree +HEAD reflog and ``git branch -D`` destroys the ref *and* its branch reflog, so a force-delete on a +branch holding unique commits leaves them reachable from **no ref and no reflog**. That logic was +covered by review only, because the script derived its repo root from ``$PSScriptRoot`` with no +override -- so the only thing a test could point it at was this checkout, which no test may touch. + +The ``-RepoRoot`` parameter (the same shape ``prune-merged.ps1`` already had, and the same reason) +is what makes these tests possible. Every test here drives the REAL script as a subprocess against a +synthetic repo built under ``tmp_path``. + +Two rules, inherited from ``tests/test_worktree_prune_merged.py``: + +* **Assert the decision and the reason, not survival.** A branch that survives proves nothing on its + own -- ``git branch -d`` refuses an unmerged branch by itself, so a survival-only assertion passes + on a script that has lost the re-verification step entirely. The keep tests therefore assert the + reported reason and the commit count as well. +* **Carry a positive control in the same invocation where one is possible.** A refusal test that only + shows a non-zero exit cannot distinguish "the guard fired" from "the script is broken", so each + refusal is followed by the invocation that is *supposed* to succeed. + +``-DeleteBranch``'s keep path is reached through ``$LASTEXITCODE`` after ``git branch -d`` fails. +That branch is only reachable while ``$PSNativeCommandUseErrorActionPreference`` is ``$false`` +(measured ``False`` on pwsh 7.6.3, which is what these tests run on). Should a future pwsh flip that +default, ``git branch -d``'s non-zero exit would become a terminating error under the script's +``$ErrorActionPreference = "Stop"`` and the keep path would stop running -- these tests would then go +red, which is the correct outcome and the reason the assertion is on the reported reason rather than +on the branch merely existing. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parents[1] +SCRIPT = _REPO / "scripts" / "worktree" / "remove.ps1" + +pytestmark = pytest.mark.skipif( + shutil.which("pwsh") is None or os.name != "nt", + reason="remove.ps1 is a PowerShell script driven through pwsh on Windows", +) + + +def _git(repo: Path, *args: str) -> str: + proc = subprocess.run( + ["git", "-C", str(repo), *args], check=True, capture_output=True, text=True + ) + return proc.stdout + + +def _git_ok(repo: Path, *args: str) -> bool: + """Run a git command for its exit status only (a non-zero exit is data, not a failure).""" + return ( + subprocess.run( + ["git", "-C", str(repo), *args], capture_output=True, text=True, check=False + ).returncode + == 0 + ) + + +def _commit(repo: Path, name: str, text: str) -> str: + (repo / name).write_text(text, encoding="utf-8") + _git(repo, "add", "--", name) + _git(repo, "commit", "-qm", f"add {name}") + return _git(repo, "rev-parse", "HEAD").strip() + + +class Fixture: + """A synthetic repo with a primary at ``/wt/repo`` and ``/wt/repo-`` siblings. + + The layout is dictated by the script under test: it derives ``/-`` from the + repo root, so the fixture has to reproduce that shape rather than choose its own. + """ + + def __init__(self, root: Path) -> None: + self.root = root + self.primary = root / "wt" / "repo" + + def sibling(self, slug: str) -> Path: + return self.primary.parent / f"{self.primary.name}-{slug}" + + def add(self, slug: str, branch: str | None = None) -> Path: + path = self.sibling(slug) + _git(self.primary, "worktree", "add", "-q", "-b", branch or slug, str(path)) + return path + + def registered(self) -> set[str]: + out = _git(self.primary, "worktree", "list", "--porcelain") + return { + ln.split(" ", 1)[1].replace("\\", "/").rstrip("/") + for ln in out.splitlines() + if ln.startswith("worktree ") + } + + def is_registered(self, path: Path) -> bool: + return str(path).replace("\\", "/").rstrip("/") in self.registered() + + def branch_exists(self, name: str) -> bool: + return _git_ok(self.primary, "rev-parse", "--verify", "--quiet", f"refs/heads/{name}") + + +@pytest.fixture +def fx(tmp_path: Path) -> Fixture: + f = Fixture(tmp_path) + f.primary.mkdir(parents=True) + _git(f.primary, "init", "-q", "-b", "main") + _git(f.primary, "config", "user.email", "t@example.invalid") + _git(f.primary, "config", "user.name", "t") + seed = _commit(f.primary, "seed.txt", "seed") + # No network and no remote: the merge re-verification reads refs/remotes/origin/main, which + # `update-ref` can write directly. + _git(f.primary, "update-ref", "refs/remotes/origin/main", seed) + return f + + +def run( + fx: Fixture, + *args: str, + script: Path | None = None, + repo_root: Path | str | None = None, +) -> subprocess.CompletedProcess[str]: + """Drive the real script. ``repo_root`` defaults to the fixture primary and is NEVER omitted + unless a test is deliberately exercising the ``$PSScriptRoot`` default against a copied script. + """ + argv = ["pwsh", "-NoProfile", "-NonInteractive", "-File", str(script or SCRIPT)] + root = fx.primary if repo_root is None else repo_root + if root != "": + argv += ["-RepoRoot", str(root)] + return subprocess.run([*argv, *args], capture_output=True, text=True, timeout=180, check=False) + + +def test_removes_a_clean_worktree(fx: Fixture) -> None: + wt = fx.add("clean") + assert fx.is_registered(wt) + + proc = run(fx, "-Name", "clean") + + assert proc.returncode == 0, proc.stderr + assert "Removed worktree" in proc.stdout + assert not wt.exists() + assert not fx.is_registered(wt) + # No -DeleteBranch: the ref is the one thing that must survive. + assert fx.branch_exists("clean") + + +def test_untracked_files_do_not_block_removal(fx: Fixture) -> None: + """The `.venv` case the header calls expected -- and it is why `--force` is unconditional.""" + wt = fx.add("venv") + (wt / ".venv").mkdir() + (wt / ".venv" / "marker.txt").write_text("not tracked anywhere", encoding="utf-8") + + proc = run(fx, "-Name", "venv") + + assert proc.returncode == 0, proc.stderr + assert not wt.exists() + + +def test_uncommitted_tracked_changes_are_refused_and_force_overrides(fx: Fixture) -> None: + wt = fx.add("dirty") + (wt / "seed.txt").write_text("modified", encoding="utf-8") + + refused = run(fx, "-Name", "dirty") + + assert refused.returncode != 0 + assert "uncommitted tracked changes" in refused.stderr + assert wt.exists() + assert fx.is_registered(wt) + + # Positive control in the same fixture: the ONLY difference is -Force, so the refusal above was + # the guard firing and not an unrelated failure. + forced = run(fx, "-Name", "dirty", "-Force") + + assert forced.returncode == 0, forced.stderr + assert not wt.exists() + assert not fx.is_registered(wt) + + +def test_delete_branch_force_deletes_only_after_reverifying_containment(fx: Fixture) -> None: + """The `-d` fails / re-verify / `-D` path -- the one that force-deletes a ref. + + Shape: the branch's commit IS on origin/main but the LOCAL main lags, which is the ordinary + state of this repo and the reason `-D` exists at all. The precondition is asserted rather than + assumed, so the test cannot silently pass through the easy `-d` path instead. + """ + wt = fx.add("landed") + tip = _commit(wt, "landed.txt", "work that has since landed upstream") + _git(fx.primary, "update-ref", "refs/remotes/origin/main", tip) + + # `git branch -d` consults the CURRENT branch, which is still at the seed: so it must refuse. + assert "landed" not in _git(fx.primary, "branch", "--merged", "main") + assert _git(fx.primary, "rev-list", "--count", "origin/main..landed").strip() == "0" + + proc = run(fx, "-Name", "landed", "-DeleteBranch") + + assert proc.returncode == 0, proc.stderr + assert not wt.exists() + assert not fx.branch_exists("landed") + # The recovery recipe is the only undo once the ref and both reflogs are gone, so it must be + # printed with the full tip, before the delete. + assert tip in proc.stdout + assert "Recover a deleted branch with" in proc.stdout + + +def test_delete_branch_keeps_a_branch_holding_commits_not_on_origin_main(fx: Fixture) -> None: + """The lossless half: unique commits mean the branch is KEPT, and the reason is reported. + + Asserting only that the branch survived would pass on a script that never re-verified anything, + because `git branch -d` refuses an unmerged branch on its own. The reported count is what + distinguishes "the re-verification ran and said keep" from "the delete simply failed". + """ + wt = fx.add("ahead") + tip = _commit(wt, "ahead.txt", "unique work, never pushed") + + proc = run(fx, "-Name", "ahead", "-DeleteBranch") + + assert proc.returncode == 0, proc.stderr + assert not wt.exists() + assert fx.branch_exists("ahead") + assert _git(fx.primary, "rev-parse", "refs/heads/ahead").strip() == tip + combined = proc.stdout + proc.stderr + assert "1 commit(s) on 'ahead' are not on origin/main" in combined + assert "KEPT" in combined + + +def test_delete_branch_reads_the_branch_from_git_not_the_directory_name(fx: Fixture) -> None: + """`-Name` is the DIRECTORY. Deleting a branch named after it would delete the wrong ref.""" + wt = fx.add("slugged", branch="claude/other") + # A decoy ref whose name IS the directory slug. If the script ever goes back to `branch -D + # $Name`, this is what it destroys. + _git(fx.primary, "branch", "slugged", "main") + + proc = run(fx, "-Name", "slugged", "-DeleteBranch") + + assert proc.returncode == 0, proc.stderr + assert not wt.exists() + assert not fx.branch_exists("claude/other") + assert fx.branch_exists("slugged") + + +def test_detached_head_refuses_delete_branch_without_removing_anything(fx: Fixture) -> None: + wt = fx.sibling("detached") + _git(fx.primary, "worktree", "add", "-q", "--detach", str(wt)) + + refused = run(fx, "-Name", "detached", "-DeleteBranch") + + assert refused.returncode != 0 + assert "detached HEAD" in refused.stderr + # The branch is read BEFORE the removal precisely so a refusal here costs nothing. + assert wt.exists() + assert fx.is_registered(wt) + + # Positive control: the same worktree, same script, without -DeleteBranch. + proc = run(fx, "-Name", "detached") + + assert proc.returncode == 0, proc.stderr + assert not wt.exists() + + +def test_refuses_a_worktree_that_does_not_exist(fx: Fixture) -> None: + proc = run(fx, "-Name", "nope") + + assert proc.returncode != 0 + assert "No such worktree" in proc.stderr + # It names the path it looked for -- the whole failure mode of BACKLOG #1078 is a caller who + # cannot tell WHICH root was searched. + assert str(fx.sibling("nope")) in proc.stderr + + +def test_a_repo_root_that_does_not_exist_is_refused(fx: Fixture) -> None: + proc = run(fx, "-Name", "clean", repo_root=fx.root / "no-such-repo") + + assert proc.returncode != 0 + assert "RepoRoot does not exist" in proc.stderr + + +def test_the_script_location_default_still_anchors_when_no_root_is_passed(fx: Fixture) -> None: + """The override must not become the only working path. + + Invoking a COPY that lives at ``/scripts/worktree/remove.ps1`` with no ``-RepoRoot`` + exercises the ``$PSScriptRoot`` default -- the path every real invocation takes -- without ever + pointing the real script at this checkout. + """ + wt = fx.add("bydefault") + copied = fx.primary / "scripts" / "worktree" / "remove.ps1" + copied.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(SCRIPT, copied) + + proc = run(fx, "-Name", "bydefault", script=copied, repo_root="") + + assert proc.returncode == 0, proc.stderr + assert not wt.exists() + assert not fx.is_registered(wt) From b7b3d9d65865f20300aa57f3039181190371904e Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 09:48:08 -0500 Subject: [PATCH 03/28] fix(oidc): bound the token-exchange request line and headers (BACKLOG #1048) The `auth` package carried zero outbound length measurement, so the one HTTP request the OIDC relying party makes -- the token exchange -- went to the opener unmeasured. Measure the request line and header block immediately before the POST and refuse with FlowError. `token_endpoint` is operator-static config (validated https at load), so this is the weaker of the two ASVS 4.2.5 limbs and the partial does not rest on it. It earns the bound anyway: an env() value that resolved to an unexpected blob now surfaces as a clear refusal instead of a wire-level surprise on the first federated login. No third copy of the limit. The guard calls transports/rest.py's find_outbound_length_violation -- the same measurement every other egress uses -- imported lazily so the pure, socket-free auth.oidc package takes no module-scope transports import, the containment store/keyprovider_vault.py already uses for the same helper. The raise stays FlowError so the caller's audited login-failure mapping still applies; only the violation class and the length are disclosed. Watched RED against the pre-fix code: the 9019-char request reached the tripwire opener. Two positive controls ship with it -- a normal endpoint must still reach the opener (so a guard that refused every exchange would not pass), and the shared bound is asserted to be the 8192 the refusal message quotes (so the number in the test is the shipped limit, not a coincidence). --- messagefoundry/auth/oidc/flow.py | 33 +++++++++++++++-- tests/test_auth_oidc.py | 63 ++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/messagefoundry/auth/oidc/flow.py b/messagefoundry/auth/oidc/flow.py index 1d717e6b..b391ae91 100644 --- a/messagefoundry/auth/oidc/flow.py +++ b/messagefoundry/auth/oidc/flow.py @@ -242,6 +242,12 @@ def exchange_code( confidential client sends ``client_secret_post``; a public client omits it and relies on PKCE. Raises :class:`FlowError` on any non-2xx, oversized, or non-JSON response — PHI/secret-safe: the secret, the ``code``, and the tokens never enter an exception message. + + The request line and header block are **measured before the POST** (ASVS 4.2.5, BACKLOG #1048). + ``token_endpoint`` is operator-static config (validated https at load), so this is the weaker of + the two 4.2.5 limbs and not attacker-influenced; it earns the bound because an ``env()`` value + that resolved to an unexpected blob then surfaces as a clear refusal here instead of a + wire-level surprise on the first federated login. """ form: dict[str, str] = { "grant_type": "authorization_code", @@ -253,13 +259,32 @@ def exchange_code( if client_secret is not None: form["client_secret"] = client_secret data = urllib.parse.urlencode(form).encode("ascii") + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + } + # Reuse the ONE outbound length measurement rather than declaring a third copy of the bound + # (transports/rest.py owns it; apiclient's duplicate is pinned equal by a test, and that + # duplication is only tolerated because ADR 0088 makes that package engine-free). Imported + # lazily so the pure, socket-free ``auth.oidc`` package takes no module-scope transports import + # — the same containment ``store/keyprovider_vault.py`` uses for the same helper. The raise is + # this module's own FlowError, not a transport exception: the caller maps FlowError to the + # audited login-failure path, and a DeliveryError there would escape unmapped. Only the class + # and the length are disclosed; the endpoint and every credential stay out of the message. + from messagefoundry.transports.rest import ( # noqa: PLC0415 (lazy — see above) + find_outbound_length_violation, + ) + + violation = find_outbound_length_violation(token_endpoint, headers) + if violation is not None: + raise FlowError( + f"the token-endpoint request {violation.kind} is {violation.length} chars, over the " + f"{violation.limit}-char limit — check [auth].oidc_token_endpoint / its env() value" + ) req = urllib.request.Request( # noqa: S310 — scheme is validated https at config load token_endpoint, data=data, - headers={ - "Content-Type": "application/x-www-form-urlencoded", - "Accept": "application/json", - }, + headers=headers, method="POST", ) try: diff --git a/tests/test_auth_oidc.py b/tests/test_auth_oidc.py index 0d136b4e..5bdc596d 100644 --- a/tests/test_auth_oidc.py +++ b/tests/test_auth_oidc.py @@ -643,6 +643,69 @@ def test_exchange_code_without_id_token_raises() -> None: ) +class _TripwireOpener: + """An opener that fails the test if it is ever reached — the shape a "refused before the wire" + claim needs. A stub that returned a body could not tell a refusal apart from a completed POST.""" + + def open(self, req: Any, timeout: float = 0.0) -> Any: + raise AssertionError("an unmeasured token-endpoint request reached the opener") + + +def test_exchange_code_refuses_an_over_length_token_endpoint() -> None: + """BACKLOG #1048 (ASVS 4.2.5): the token-exchange POST had no send-time length guard — the whole + ``auth`` package carried zero length measurement, so the one outbound request the OIDC relying + party makes was the unmeasured limb of the 4.2.5 partial. + + ``token_endpoint`` is operator-static config (validated https at load), so this is the weaker of + the two limbs and not attacker-influenced. It still earns the bound: an ``env()`` value that + resolved to an unexpected blob surfaces here as a clear refusal instead of a wire-level surprise + on the first federated login. + + Mutation: delete the ``find_outbound_length_violation`` call in ``exchange_code``. Red: + AssertionError from the tripwire — the over-long request reached the opener.""" + with pytest.raises(oidc.FlowError, match="over the 8192-char limit"): + oidc.exchange_code( + token_endpoint="https://idp.example/" + "t" * 9000, + client_id="c", + client_secret=None, + code="x", + redirect_uri="http://localhost/cb", + code_verifier="v", + opener=_TripwireOpener(), # type: ignore[arg-type] + ) + + +def test_exchange_code_length_guard_reuses_the_one_outbound_bound() -> None: + """The limit is not re-declared in ``auth``: the guard calls the same measurement every other + HTTP egress uses, so there is one definition of "too long" and no third constant to drift. + + Live positive control for the refusal above — it proves the number that test matches is the + shipped limit rather than a value that merely happens to agree with it.""" + from messagefoundry.transports.rest import MAX_OUTBOUND_URL_LEN + + assert MAX_OUTBOUND_URL_LEN == 8192, ( + "the shared outbound URL bound moved; the OIDC guard follows it automatically but the " + "message the refusal test matches does not" + ) + + +def test_exchange_code_still_posts_an_endpoint_that_fits() -> None: + """Positive control: a normal endpoint must still reach the opener. Without it, a guard that + refused EVERY token exchange would pass the refusal test above.""" + opener = _FakeOpener(json.dumps({"id_token": "x.y.z"}).encode()) + payload = oidc.exchange_code( + token_endpoint="https://idp.example/token", + client_id="c", + client_secret=None, + code="x", + redirect_uri="http://localhost/cb", + code_verifier="v", + opener=opener, # type: ignore[arg-type] + ) + assert payload["id_token"] == "x.y.z" + assert opener.request is not None, "the request must have been handed to the opener" + + def test_non_ascii_state_is_a_plain_non_match_not_a_crash() -> None: """``state`` is raw query-string input, and ``hmac.compare_digest`` RAISES TypeError on a str carrying non-ASCII. Comparing directly would turn ``?state=café`` into an unhandled 500 on an From 794a8c62d9cd02b78749c56f6979745e9043d085 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 09:50:33 -0500 Subject: [PATCH 04/28] fix(worktree): print cleanup advice that runs as written (BACKLOG #1078) new.ps1 anchors on $PSScriptRoot, so run from a linked worktree it creates the new tree beside ITSELF -- under .claude/worktrees/, say -- and then told the reader to run remove.ps1 from the primary. remove.ps1 anchors on its own location too, so the primary copy derives /-, a path that does not exist, and throws "No such worktree". The anchoring is correct (#1060); the sentence was not, and #1078 is explicit that the placement must not change. new.ps1 now prints two commands, each carrying $RepoRoot explicitly so it works from any cwd outside the worktree: pwsh -NoProfile -File "\scripts\worktree\remove.ps1" -Name git -C "" worktree remove --force "" The first is the one to reach for -- it keeps the uncommitted-tracked-changes guard, which the bare git call does not. --force on the second because the untracked .venv makes git consider the worktree non-empty. The same false claim was in remove.ps1's own header and in docs/WORKTREES.md; both are corrected here, because fixing one site and leaving two is how a corrected fact comes back. ADVICE THAT IS ONLY READ IS ADVICE NOTHING CHECKS, so the test EXECUTES it. tests/test_worktree_new_cleanup_advice.py extracts each printed command from the script (an extraction contract new.ps1 now states on its side: " # " is a command, " # " is prose), substitutes the paths, and runs it against a synthetic repo, requiring the worktree to be gone AND deregistered. Red first, and the control is live. All three tests failed before the change; the third failed only AFTER reproducing the defect -- it builds a linked checkout `inner`, creates `inner-feature` beside it the way new.ps1 would, and asserts the retired advice still throws today: assert retired.returncode != 0 PASSED assert "No such worktree" in stderr PASSED ...naming /repo-feature PASSED assert not target.exists() FAILED <- no working advice yet so the green it reports now is evidence the assertion can see the class. An end-to-end run of the real new.ps1 (copied into a synthetic repo, -NoInstall, invoked from the linked worktree) printed both commands with the linked root filled in, and the first one removed the worktree verbatim. --- docs/WORKTREES.md | 9 +- scripts/worktree/new.ps1 | 20 +- scripts/worktree/remove.ps1 | 11 +- tests/test_worktree_new_cleanup_advice.py | 230 ++++++++++++++++++++++ 4 files changed, 264 insertions(+), 6 deletions(-) create mode 100644 tests/test_worktree_new_cleanup_advice.py diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index cf105fa5..720085d3 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -48,12 +48,19 @@ in parallel without touching each other's files. ## Remove one -Run from the **main** checkout (git can't remove the worktree you're standing in): +**Which copy you run decides which checkout it searches.** `remove.ps1` anchors on its own location, +not on your cwd, so run the copy that lives in the checkout the worktree was created **from** — which +is not necessarily the primary, because `new.ps1` anchors the same way and creates its worktree +beside *itself*. Invoked by absolute path it works from any cwd outside the worktree being removed +(git can't remove the worktree you're standing in). This page used to say "run from the main +checkout"; that was false for every worktree created from a linked one, and `new.ps1` now prints the +exact command with the root already filled in. (BACKLOG #1078.) ```powershell scripts\worktree\remove.ps1 -Name alerts # refuses if there are uncommitted tracked changes scripts\worktree\remove.ps1 -Name alerts -DeleteBranch scripts\worktree\remove.ps1 -Name alerts -Force # discard uncommitted tracked changes too +pwsh -NoProfile -File \scripts\worktree\remove.ps1 -Name alerts # from any cwd ``` The untracked `.venv` / `node_modules` are expected and removed automatically; only uncommitted diff --git a/scripts/worktree/new.ps1 b/scripts/worktree/new.ps1 index b85a8ea8..4dc0411d 100644 --- a/scripts/worktree/new.ps1 +++ b/scripts/worktree/new.ps1 @@ -177,9 +177,23 @@ function Show-NextSteps { Write-Host " code --extensionDevelopmentPath=`"$WorktreePath\ide`" `"$WorktreePath\mefor.code-workspace`"" } Write-Host " # ... build/commit/push on branch '$Branch'; open a PR as usual." - # -Name here is the DIRECTORY, which is what remove.ps1 takes -- correct as-is even when it differs - # from the branch. - Write-Host " # When done (run from the MAIN checkout): scripts\worktree\remove.ps1 -Name $Name" + # THE CLEANUP ADVICE NAMES $RepoRoot, NOT "the MAIN checkout" (BACKLOG #1078). This script anchors + # on $PSScriptRoot, so run from a linked worktree it creates the new tree beside ITSELF -- and + # remove.ps1 anchors on its own location too, so the MAIN checkout's copy would derive + # \-$Name, a path that does not exist, and throw "No such worktree". The + # anchoring is correct (#1060); the sentence was not. Both forms below carry the root explicitly, + # so they work from any cwd. -Name here is the DIRECTORY, which is what remove.ps1 takes -- + # correct even when it differs from the branch. + # + # EXTRACTION CONTRACT: a line beginning " # " (hash, THREE spaces) is a command meant to be run + # verbatim; " # " (hash, one space) is prose. tests/test_worktree_new_cleanup_advice.py extracts + # the former and EXECUTES it against a synthetic repo, because advice that is only read is advice + # nothing checks. Keep the two shapes distinct. + Write-Host " # When done, from any directory OUTSIDE the worktree, either of:" + Write-Host " # pwsh -NoProfile -File `"$RepoRoot\scripts\worktree\remove.ps1`" -Name $Name" + Write-Host " # git -C `"$RepoRoot`" worktree remove --force `"$WorktreePath`"" + # --force because the untracked .venv makes git consider the worktree non-empty. The first form is + # the one to reach for: it refuses on uncommitted TRACKED changes, which the bare git call does not. } if ($NoInstall) { diff --git a/scripts/worktree/remove.ps1 b/scripts/worktree/remove.ps1 index a41b6ca0..c84f2b4a 100644 --- a/scripts/worktree/remove.ps1 +++ b/scripts/worktree/remove.ps1 @@ -5,8 +5,15 @@ .DESCRIPTION Removes the sibling worktree directory -. Refuses if the worktree has uncommitted *tracked* changes (so you don't lose work) unless -Force; the untracked .venv / node_modules are - expected and removed automatically. Run from the MAIN checkout (git can't remove the worktree - you're standing in). See docs/WORKTREES.md. + expected and removed automatically. + + WHICH COPY YOU RUN DECIDES WHICH CHECKOUT IT SEARCHES. This anchors on $PSScriptRoot (or -RepoRoot), + not on your cwd, so run the copy living in the checkout the worktree was created FROM -- which is + not necessarily the primary, since new.ps1 anchors the same way and creates its worktree beside + itself. By absolute path it works from any cwd outside the worktree being removed (git can't + remove the worktree you're standing in). This header used to name the primary checkout + unconditionally, which was false for every worktree created from a linked one; new.ps1 now prints + the exact command with the root already filled in (BACKLOG #1078). See docs/WORKTREES.md. -Name is the DIRECTORY component only. -DeleteBranch deletes whichever branch that worktree actually has checked out, read from git -- not a branch named after the directory. Since new.ps1 diff --git a/tests/test_worktree_new_cleanup_advice.py b/tests/test_worktree_new_cleanup_advice.py new file mode 100644 index 00000000..3f5d928c --- /dev/null +++ b/tests/test_worktree_new_cleanup_advice.py @@ -0,0 +1,230 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""``new.ps1``'s cleanup advice must be runnable as written (BACKLOG #1078). + +THE DEFECT. ``new.ps1`` anchors on ``$PSScriptRoot``, so run from a linked worktree it creates the +new tree beside **itself** -- e.g. under ``.claude/worktrees/`` -- and then printed *"When done (run +from the MAIN checkout): scripts\\worktree\\remove.ps1 -Name "*. ``remove.ps1`` anchors on its +own location too, so the main checkout's copy derives ``/-``, which +does not exist, and it throws ``No such worktree``. The anchoring is correct (BACKLOG #1060); the +sentence was wrong. + +THE INSTRUMENT. A text assertion that the advice "mentions $RepoRoot" would pass on advice that is +still unrunnable, so the commands are **extracted from the script and executed** against a synthetic +repo. ``test_the_advice_that_used_to_be_printed_still_throws_today`` carries the live positive +control in the same test as the fix: the old ``main checkout`` form is run against the very fixture +the new form then cleans up successfully, so a green here is evidence that the assertion can see the +class rather than evidence that nothing was checked. + +THE EXTRACTION CONTRACT, which ``new.ps1`` states on its side too: inside ``Show-NextSteps`` an +advice line whose text begins ``" # "`` (hash, then THREE spaces) is a command meant to be run +verbatim; ``" # "`` (hash, one space) is prose. Keeping the two shapes distinct is what lets a test +run the advice instead of merely reading it. +""" + +from __future__ import annotations + +import os +import re +import shlex +import shutil +import subprocess +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parents[1] +_NEW_PS1 = _REPO / "scripts" / "worktree" / "new.ps1" +_REMOVE_PS1 = _REPO / "scripts" / "worktree" / "remove.ps1" + +pytestmark = pytest.mark.skipif( + shutil.which("pwsh") is None or os.name != "nt", + reason="new.ps1/remove.ps1 are PowerShell scripts driven through pwsh on Windows", +) + +#: An advice line carrying a COMMAND (see the extraction contract above). +_ADVICE_COMMAND = re.compile(r'^\s*Write-Host\s+" # (?P.*)"\s*$') + +#: The stale claim the item is about, as a LITERAL tripwire across both scripts. Deliberately blunt: +#: it cannot tell an instruction from a retraction quoting one, so a note recording that the claim was +#: removed must PARAPHRASE it rather than quote it. That is the cheaper half of the trade -- a smarter +#: pattern would have to decide which sentences are imperative, and would be the thing to get wrong. +_STALE_CLAIM = re.compile(r"run from the MAIN checkout", re.IGNORECASE) + + +def _advice_commands() -> list[str]: + """The raw (still ``$``-templated) command lines new.ps1 prints as cleanup advice.""" + return [ + m.group("cmd") + for m in ( + _ADVICE_COMMAND.match(ln) for ln in _NEW_PS1.read_text(encoding="utf-8").splitlines() + ) + if m + ] + + +def _render(cmd: str, *, repo_root: Path, worktree: Path, name: str) -> list[str]: + """Turn one printed advice line into an argv, exactly as a reader copying it would. + + Longest token first so no substitution eats a prefix of another. + """ + text = cmd.replace('`"', '"') + for token, value in ( + ("$WorktreePath", str(worktree)), + ("$RepoRoot", str(repo_root)), + ("$Name", name), + ): + text = text.replace(token, value) + assert "$" not in text, f"advice line still carries an unsubstituted variable: {text}" + return [t.strip('"') for t in shlex.split(text, posix=False)] + + +def _git(repo: Path, *args: str) -> str: + proc = subprocess.run( + ["git", "-C", str(repo), *args], check=True, capture_output=True, text=True + ) + return proc.stdout + + +def _registered(primary: Path) -> set[str]: + out = _git(primary, "worktree", "list", "--porcelain") + return { + ln.split(" ", 1)[1].replace("\\", "/").rstrip("/") + for ln in out.splitlines() + if ln.startswith("worktree ") + } + + +class Fixture: + """A synthetic repo that carries a real copy of ``remove.ps1`` at the tracked path. + + Committing the script means every checkout of the fixture -- primary and linked -- has it where + ``$RepoRoot\\scripts\\worktree\\remove.ps1`` resolves, which is the whole point of the advice + naming a root. + """ + + def __init__(self, root: Path) -> None: + self.root = root + self.primary = root / "wt" / "repo" + + def worktree(self, path: Path, branch: str) -> Path: + _git(self.primary, "worktree", "add", "-q", "-b", branch, str(path)) + # The untracked entry every real worktree carries. `git worktree remove` refuses on it + # without --force, so advice that omits --force would fail here -- which is the point. + (path / ".venv").mkdir() + (path / ".venv" / "marker.txt").write_text("untracked", encoding="utf-8") + return path + + +@pytest.fixture +def fx(tmp_path: Path) -> Fixture: + f = Fixture(tmp_path) + tracked = f.primary / "scripts" / "worktree" + tracked.mkdir(parents=True) + shutil.copyfile(_REMOVE_PS1, tracked / "remove.ps1") + _git(f.primary, "init", "-q", "-b", "main") + _git(f.primary, "config", "user.email", "t@example.invalid") + _git(f.primary, "config", "user.name", "t") + _git(f.primary, "add", "--", "scripts/worktree/remove.ps1") + _git(f.primary, "commit", "-qm", "seed") + _git( + f.primary, + "update-ref", + "refs/remotes/origin/main", + _git(f.primary, "rev-parse", "HEAD").strip(), + ) + return f + + +def _run(argv: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(argv, capture_output=True, text=True, timeout=180, check=False) + + +def test_the_advice_is_a_non_empty_set_of_commands_naming_the_checkout(fx: Fixture) -> None: + """Non-vacuity first: an empty extraction would make every execution test below pass silently.""" + commands = _advice_commands() + + assert commands, ( + f"{_NEW_PS1.name} prints no cleanup command in the extractable form. If Show-NextSteps was " + "restructured, re-point this guard rather than letting it pass on an empty set." + ) + rootless = [c for c in commands if "$RepoRoot" not in c] + assert not rootless, ( + "a cleanup command does not name the checkout the worktree belongs to:\n " + + "\n ".join(rootless) + + "\nA root-less command resolves against the reader's cwd, which is the BACKLOG #1078 defect: " + "the main checkout's remove.ps1 looks for a sibling that does not exist and throws." + ) + # Both scripts carried the claim, and remove.ps1's own header is the one a reader hits after + # following the advice. Fixing one site and leaving the other is how a corrected fact comes back. + stale = [ + f"{p.name}: {ln.strip()}" + for p in (_NEW_PS1, _REMOVE_PS1) + for ln in p.read_text(encoding="utf-8").splitlines() + if _STALE_CLAIM.search(ln) + ] + assert not stale, ( + 'a worktree script still asserts "run from the MAIN checkout":\n ' + + "\n ".join(stale) + + "\nThat is false whenever new.ps1 itself ran from a linked worktree." + ) + + +def test_every_printed_cleanup_command_actually_removes_the_worktree(fx: Fixture) -> None: + """Follow the advice as written, once per printed command, and require the worktree to be gone.""" + commands = _advice_commands() + assert commands + + for i, cmd in enumerate(commands): + wt = fx.worktree(fx.primary.parent / f"repo-adv{i}", f"adv{i}") + assert str(wt).replace("\\", "/") in _registered(fx.primary) + + proc = _run(_render(cmd, repo_root=fx.primary, worktree=wt, name=f"adv{i}")) + + assert proc.returncode == 0, f"advice failed: {cmd}\n{proc.stdout}\n{proc.stderr}" + assert not wt.exists(), f"advice ran but left the worktree behind: {cmd}" + assert str(wt).replace("\\", "/") not in _registered(fx.primary), ( + f"advice ran but the worktree is still registered: {cmd}" + ) + + +def test_the_advice_that_used_to_be_printed_still_throws_today(fx: Fixture) -> None: + """The live positive control, in the shape that produced the item. + + ``inner`` stands in for a linked worktree new.ps1 was run from; ``inner-feature`` is the tree it + would then create beside itself. The retired advice -- the MAIN checkout's own relative + ``remove.ps1 -Name feature`` -- is run against that tree and must still fail, and the advice + printed today must then clean it up. One test, both directions. + """ + inner = fx.primary / ".claude" / "worktrees" / "inner" + _git(fx.primary, "worktree", "add", "-q", "-b", "inner", str(inner)) + target = fx.worktree(inner.parent / "inner-feature", "feature") + + retired = _run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(fx.primary / "scripts" / "worktree" / "remove.ps1"), + "-Name", + "feature", + ] + ) + + assert retired.returncode != 0, ( + "the retired advice succeeded, so this control proves nothing -- re-derive it before trusting " + "the assertions above" + ) + assert "No such worktree" in retired.stderr + assert str(fx.primary.parent / "repo-feature") in retired.stderr + assert target.exists() + + for cmd in _advice_commands(): + if not target.exists(): + break + proc = _run(_render(cmd, repo_root=inner, worktree=target, name="feature")) + assert proc.returncode == 0, f"advice failed: {cmd}\n{proc.stdout}\n{proc.stderr}" + + assert not target.exists() + assert str(target).replace("\\", "/") not in _registered(fx.primary) From 52a20c8c49c1009f3bfb72c80f1e10a5cea28600 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 09:51:50 -0500 Subject: [PATCH 05/28] backlog: reconcile the live ledger against the shipped code (BACKLOG #327, #1099, #1200, #1201, #1206, #1207, #1209, #64) Seven banners flipped to closed, each confirmed by OPENING THE SHIPPED CODE rather than by trusting a report or a commit message, plus one prose-only reconciliation. Confirmed in the code, not inherited: #1200 ci.yml carries alwayscode='\.(py|ps1|sh|ts|js|yml|yaml|toml|lock|cfg|ini)$' and evaluates it in the FIRST elif, ahead of alwayscodepath and noncode. Re-driven with the regexes read back out of ci.yml. #1201 _is_secret_header() ends in a substring test over auth|token|secret|credential|password|passphrase|key, with the five original names kept as a floor and a second VALUE arm for opaque vendor names. #1206 redacted_settings() has an odbc_params arm; _is_secret_odbc_key() is shape-based and case-insensitive, with the libpq PATH keywords excluded. #1207 _redact_header_value() opens with the EnvRef arm; _mask_url_userinfo() replaces only the password half. Both reach display_settings by delegation. #1209 the || binds the ASSIGNMENT, outside the substitution, plus a shape test. Re-executed under bash -e against a gh stub reproducing the stream split: pre-fix emits advisory_ok=true (FAIL OPEN), shipped emits false. #327 tests/test_private_paths_stay_ignored.py pins all six rules with a cardinality assertion. PROVED ABLE TO FAIL: removing /docs/security/ from .gitignore reds it by name; restored byte-clean. #327's residual is fixed in this commit. docs/SESSION-DRIFT-CONTROLS.md linked [.claude/settings.json](../.claude/settings.json) -- a path no reader outside the maintainer's machine has -- and presented the blanket-stage hook as an active control. The link is removed rather than repaired and the guard's real reach is stated: the script is tracked, its PreToolUse matcher is not, so a fresh clone and every worktree come up without it. link_check.py could never have caught that: .claude/ is in its WITHHELD set and the exemption continues BEFORE the counter increments. Measured by planting two hrefs -- a missing non-withheld path took the run red and the total 5359->5360; the same path under .claude/ left it green AND the total unchanged. The href was not resolved, it was never counted. #1099 corrects #1094's "the archival pass generates the anchor ... derive it from the generator". There is no archival tooling: the only tracked paths matching "archiv" are seven documents. Two corrections to #1099's own text, both the class it was filed about -- the sentence was in the LIVE file, not the archive, and it cited tests/test_archive_link_resolution.py, which is on no merged ref (PR #281 squashed as 6cb34f5f and the file landed as tests/test_link_resolution.py). Found by searching every ref. The same stale name in #1095's block was corrected with it. #64 is PROSE ONLY and STAYS OPEN. Step 1 ran 2026-07-12; step 2 is REFUSED, not gated (ADR 0055 withdrawn on two independent grounds, ADR 0107 closes Phase 4). The 2026-06-28 "honest verdict" figures are corrected in place: "compute unvalidated" is falsified (C5 pins per-shard R in [2,3) against the 3.62 a cleared N=16 needs), and "~7 commits/msg, group-commit unbuilt" is wrong in both halves (10.4746 committed_txns/msg measured; the commit tier is ~9% utilised). What survives is only the index role over #62/#63/#47/#34. Whether that umbrella is discharged is an owner call and was deliberately not taken. HELD FOR THE OWNER (G28): the two redaction route-onwards found under #1201 and #1206 -- an env() ref in a headers table, and env() resolution inside nested settings -- are marked "pending owner ledger decision" in place. No number was allocated and neither was quietly closed as prose. Gates: backlog_status_check 477 items / 282 live + 195 archive, link_check 5369 links across 347 files, ledger_check clean. --- docs/BACKLOG.md | 187 ++++++++++++++++++++++++++------- docs/SESSION-DRIFT-CONTROLS.md | 18 +++- 2 files changed, 162 insertions(+), 43 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 96566463..839c3a0c 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -965,7 +965,7 @@ Corepoint 45M/day spec parity analysis. ## 64. Throughput parity with Corepoint — measure-first performance roadmap (group-commit + lean-writes, gated on the enterprise-box validation) (P2, owner / measure-gated) -> 🔢 **Re-scored 2026-08-03 → P3.** Value **1/10** · Difficulty **1/10** · _fill-in_. An index over levers that live in #62/#63/#47/#34, so it ships nothing runnable of its own, and the remainder is reconciling roadmap prose against a measurement that has already run and a lever already abandoned — a doc edit. But the gate this item was demand-gated ON has FIRED (ADR 0051 measure-first complete 2026-07-12; ADR 0099 → ABANDON; ADR 0107 closes Phase 4), so the DEMAND-GATE override no longer applies and the tier derives from the score: P3, fill-in. _(was 1/10 · 2/10.)_ +> 🔢 **OPEN — and what survives under this number is ONLY its index role over the storage-efficiency cluster (#62/#63/#47/#34).** Prose reconciled 2026-08-10 (BACKLOG #64). The throughput half of this roadmap is closed **by measurement, not deferred by it**: step 1 RAN, step 2 is **REFUSED rather than gated**, and the 2026-06-28 "honest verdict" figures below are corrected in place against the runs that superseded them. **Nothing schedulable remains here** — the cluster is scheduled under its own numbers, and steps 4–5 are re-read-first. Whether this umbrella is therefore discharged is an **owner decision**, deliberately not taken by the reconciliation. Value **1/10** · Difficulty **1/10** · _fill-in_ (re-scored 2026-08-03 → P3 once the demand-gate fired; _was 1/10 · 2/10_). > ⚠️ **AMENDED 2026-08-03 — the measure-first gate has RUN, and step 2 of the ordered plan is REFUSED, not gated.** The plan below still reads live — "Nothing builds before it" at step 1, and group-commit as "the #1 unbuilt durable-write lever … when built — *iff* the run shows durable-write-bound" at step 2 — but the measure-first phase completed 2026-07-12 on rig runs C1–C7 ([ADR 0051](adr/0051-corepoint-throughput-parity-strategy.md), banner at `:3`), group-commit itself was withdrawn ([ADR 0055](adr/0055-group-commit-durable-write.md)`:3-4`, "⛔ SUPERSEDED / WITHDRAWN … DO NOT BUILD THIS"), and the one surviving transaction-reduction lever was falsified by the pre-registered P0 run of 2026-07-13 — the intervention engaged (`committed_txns/msg` −28.5%) while throughput moved −0.56%, inside the pre-registered null band — closing Phase 4 ([ADR 0107](adr/0107-phase-4-is-closed-transaction-reduction-is-a-measured-dead-end.md)`:3` "Do not build F2 or F3", `:7` terminating ADR 0057 as "⛔ DO NOT PROMOTE", table at `:38-40`). ⚠️ **Do not read step 2 as schedulable.** What survives is at least step 3 — this item's index role over the storage-efficiency cluster (#62/#63/#47/#34), which the throughput measurement does not bear on; **re-read steps 4–5 against [ADR 0098](adr/0098-store-side-scaling-levers-are-exhausted-transaction-amortization-is-the-only-path-to-45m-day.md) before scheduling either**, and read the 2026-06-28 "honest verdict" figures below as pre-measurement history, not as the current state. @@ -978,28 +978,67 @@ leading performance driver**. The strategy + the **no-rewrite / no-broker** deci [**ADR 0051**](adr/0051-corepoint-throughput-parity-strategy.md); the engineering note is [`THROUGHPUT-IMPROVEMENTS.md`](archive/throughput/THROUGHPUT-IMPROVEMENTS.md) §5. -**Honest verdict (2026-06-28).** NOT at demonstrated parity at 45M/day (the earlier "at parity" claim was vs +**Honest verdict (2026-06-28) — PRE-MEASUREMENT HISTORY. Two of its six clauses are falsified; corrections +follow immediately below and are what to read.** Kept dated rather than rewritten, because it is the record +the plan was built on. NOT at demonstrated parity at 45M/day (the earlier "at parity" claim was vs Rhapsody *marketing*, not this spec): **compute** unvalidated (only `E_core ≈ 42 msg/s` measured on an under-powered box; 84/400 estimated); **durable-write** behind (~7 commits/msg, group-commit unbuilt); **storage** higher but mostly **by construction** — carriage (`NVARCHAR(MAX)` 2 B/char + base64) + encrypt-by-default, **not** inefficiency (the "~2× vs Corepoint" was estimate-vs-brochure, **retracted**); **HA / multi-DB maturity** behind; **cost / openness** ahead. -**Ordered plan (each step gated on the one before):** -1. **Measure first (the gate).** Enterprise-hardware `E_core` + sustained durable-write IOPS run — the +> **CORRECTED 2026-08-10 (BACKLOG #64) — the two clauses the runs falsified.** Figures taken from the ADRs, +> not restated from this file. +> +> - **"compute unvalidated" is no longer true.** The enterprise-hardware run this item gated everything on +> **completed 2026-07-12** (rig runs C1–C7, +> [ADR 0051](adr/0051-corepoint-throughput-parity-strategy.md) banner). It did not land on the hoped-for +> 84 or 400: **C5 pins the per-shard ceiling at `R ∈ [2,3)`**, below the **3.62/shard** a cleared N=16 +> would need, so **N-sizing alone is insufficient** — recorded as +> [ADR 0098](adr/0098-store-side-scaling-levers-are-exhausted-transaction-amortization-is-the-only-path-to-45m-day.md). +> C6 found **no convoy** (0 of 288 samples): there is **no single blocker to rewrite**. +> - **"durable-write behind (~7 commits/msg, group-commit unbuilt)" is wrong in both halves.** The measured +> figure is **10.4746 `committed_txns/msg`** on P0's control arm +> ([ADR 0107](adr/0107-phase-4-is-closed-transaction-reduction-is-a-measured-dead-end.md) table). And the +> commit tier is **not** where this is behind: the store absorbs **~27–29k commits/s** against **~2,416 +> commits/s** of demand at the *full* 45M/day target — **~9% utilised** +> ([ADR 0055](adr/0055-group-commit-durable-write.md) withdrawal banner). You cannot buy throughput by +> consuming less of a resource you are barely touching. Group-commit is not "unbuilt" pending a decision; +> it is **WITHDRAWN — DO NOT BUILD**. +> - **The other four clauses stand as written** — the throughput measurement does not bear on carriage, +> HA/multi-DB maturity, or cost/openness, and the "~2× vs Corepoint" retraction was already recorded. + +**Ordered plan (each step gated on the one before) — status reconciled 2026-08-10; the step text is the +2026-06-28 original, each verdict is current:** +1. **RAN, 2026-07-12 — this gate is discharged.** Measure first: enterprise-hardware `E_core` + sustained + durable-write IOPS run — the **Windows Server 2025 + SQL Server 2025 box (#40)** via the load harness (#28 / #29) — against the **9,200-IOPS / ~11 KB-msg / 20 + 16-core** target. Pins `E_core` (42 vs 84 vs 400) + the binding axis. - **Nothing builds before it.** -2. **Group-commit** — the #1 unbuilt durable-write lever ([`THROUGHPUT-IMPROVEMENTS.md`](archive/throughput/THROUGHPUT-IMPROVEMENTS.md) - §2); its **own ADR** when built — *iff* the run shows durable-write-bound. -3. **Lean-writes / carriage cluster** — **#62** (VARBINARY carriage) / **#63** (`message_events` knob) / - **#47** (embedded-doc pruning) / **#34** (retention). -4. **Multi-DB log split** — **shared-server backend only** (the atomic staged-queue transaction can't be split). + **Nothing builds before it.** *(Rig runs C1–C7; see the corrections above for what it returned.)* +2. **REFUSED — not gated, not deferred. DO NOT BUILD.** Group-commit — the #1 unbuilt durable-write lever + ([`THROUGHPUT-IMPROVEMENTS.md`](archive/throughput/THROUGHPUT-IMPROVEMENTS.md) + §2); its **own ADR** when built — *iff* the run shows durable-write-bound. **The *iff* resolved FALSE + and the lever was then withdrawn on two independent grounds** + ([ADR 0055](adr/0055-group-commit-durable-write.md) `:3-4`): its premise is measured false (~9% commit-tier + utilisation), and `DELAYED_DURABILITY` would ACK a message that a power failure loses — breaking the + CLAUDE.md §2 ACK-after-durable-commit invariant, on PHI. The one surviving transaction-reduction lever was + then falsified by the pre-registered P0 run and **Phase 4 closed** + ([ADR 0107](adr/0107-phase-4-is-closed-transaction-reduction-is-a-measured-dead-end.md) — "Do not build F2 + or F3", terminating ADR 0057 as "DO NOT PROMOTE"). **A reader who schedules this step is building + something two ratified ADRs forbid.** +3. **THE SURVIVING SCOPE — and it is an index, not a build.** Lean-writes / carriage cluster: **#62** + (VARBINARY carriage) / **#63** (`message_events` knob) / + **#47** (embedded-doc pruning) / **#34** (retention). Each is scheduled under **its own** number; this + item contributes the grouping and nothing runnable. +4. **RE-READ FIRST, do not schedule from this line.** Multi-DB log split — **shared-server backend only** (the atomic staged-queue transaction can't be split). Both this and step 5 were written **before** the store-side levers were measured; check them against [ADR 0098](adr/0098-store-side-scaling-levers-are-exhausted-transaction-amortization-is-the-only-path-to-45m-day.md) (four store-side dead ends) before either is proposed. Note also that DBSHARD in step 5 is a **database shard** (ADR 0039, shelved), not an engine shard. 5. **Deferred contingencies** — the scoped native engine-service core, free-threading (ADR 0040), DBSHARD (ADR 0039) — revisited only if the measurement shows machinery-bound and/or the single-hot-feed case matters. -**Priority / gating.** P2, **owner / measure-gated** — the roadmap exists; the build of each lever waits on the -validation run. Sibling to **#52** (Corepoint *capability* parity). Decision: +**Priority / gating.** P3, **index only** — reconciled 2026-08-10. The heading and the line that stood here +both read *"P2, owner / measure-gated — the build of each lever waits on the validation run"*; **the +validation run has been taken**, so nothing here waits on it any longer and no lever under this number is +awaiting a gate. The heading is left at its original wording because it is this item's anchor. Sibling to +**#52** (Corepoint *capability* parity). Decision: [ADR 0051](adr/0051-corepoint-throughput-parity-strategy.md). Plan doc: [`THROUGHPUT-IMPROVEMENTS.md`](archive/throughput/THROUGHPUT-IMPROVEMENTS.md) §5. Surfaced by the 2026-06-28 Corepoint 45M/day spec parity analysis. @@ -2775,7 +2814,7 @@ No test covers it. `tests/test_scan_tokens_source.py:559-583` (`test_absolute_ho ## 327. No test asserts the private-path `.gitignore` block still ignores anything -> 🔢 **Filed 2026-08-01 — not started.** Value **6/10** · Difficulty **2/10** · _quick win_. Six `.gitignore` rules are the sole control keeping maintainer-internal security material out of a public commit since the publish deny-list was retired, and the repo-wide search for `check-ignore` matches exactly one hand-run script (`scripts/dev/setup-leak-gate.ps1:58`) covering a different file, so the boundary is defended by review attention plus a hook that lives inside the now-ignored `/.claude/` tree and no fresh clone gets; a pinned-literal test with a synthetic probe child, plus dropping `^\.gitignore$` from the `noncode` allowlist at `.github/workflows/ci.yml:658` — without that edit the guard goes green on exactly the PR it exists to catch. +> ✅ **CLOSED 2026-08-10 — Proposed 1-3 shipped in `dddbdc32`, and the guard was PROVED ABLE TO FAIL rather than merely observed green.** `tests/test_private_paths_stay_ignored.py` pins all six rules in a literal `_PRIVATE_PATHS` list, asserts `git check-ignore -q` on a synthetic probe child plus an empty `git ls-files` per prefix, and carries a `len(_PRIVATE_PATHS) == 6` cardinality assertion so deleting an entry cannot silently delete its coverage. 13 passed. Made to fail on purpose 2026-08-10 by removing `/docs/security/` from `.gitignore`: RED, naming the rule (*"'docs/security/probe-327.md' is NOT ignored"*), restored byte-clean. The CI half is wired — `ci.yml` `alwayscodepath='^(\.gitattributes|\.gitignore)$'`, driven live, classifies a `.gitignore`-only change as **code**, so the guard fires on exactly the PR shape it exists to catch. The prose residual is fixed in the same change as this closure. Filed 2026-08-01. Value **6/10** · Difficulty **2/10** · _quick win_. Six `.gitignore` rules are the sole control keeping maintainer-internal security material out of a public commit since the publish deny-list was retired, and the repo-wide search for `check-ignore` matches exactly one hand-run script (`scripts/dev/setup-leak-gate.ps1:58`) covering a different file, so the boundary is defended by review attention plus a hook that lives inside the now-ignored `/.claude/` tree and no fresh clone gets; a pinned-literal test with a synthetic probe child, plus dropping `^\.gitignore$` from the `noncode` allowlist at `.github/workflows/ci.yml:658` — without that edit the guard goes green on exactly the PR it exists to catch. **Cluster:** Security / Publishing boundary. **Priority:** P2. **Verdict:** build. **Severity:** medium. @@ -2816,6 +2855,27 @@ The two nearest-looking guards are neither: `tests/test_scaffold.py:51-52` asser **Also (small, same block):** `docs/SESSION-DRIFT-CONTROLS.md:69-71` links to `[.claude/settings.json](../.claude/settings.json)`, which `/.claude/` now ignores and which is untracked — the link cannot resolve in the public repo, and the paragraph presents the blanket-`git add -A` guard as an active control while pointing at a file no public reader has. Fix in the same commit. The stale comment above the `.claude/settings.local.json` rule ("settings.json is shared/tracked") is contradicted by the later `/.claude/` rule at `:142` and should go with it. +> **DONE 2026-08-10, with one residual named.** The dead link is removed (not repaired — it named a path +> no reader outside the maintainer's machine has) and the paragraph now states the guard's real reach: +> the script is tracked, its `PreToolUse` matcher is not, so a fresh clone and every `git worktree add` +> come up without it. +> +> **`scripts/docs/link_check.py` could never have caught this, which is the part worth keeping.** +> `.claude/` sits in that script's `WITHHELD` tuple, and the exemption `continue`s **before** `checked +> += 1`. Measured 2026-08-10 by planting two hrefs in a tracked document: a missing **non-withheld** +> path took the run to `FAIL: 1 unresolved` and the link total from 5359 to 5360; the same missing path +> under `.claude/` left the run `OK` **and the total unchanged at 5359** — the href was not merely +> resolved, it was never counted. A green link gate is not evidence about this class. (The same trap is +> already recorded from the other side in `tests/test_link_resolution.py`, whose first repo-wide +> measurement undercounted by 7 because `.claude/` was *present* in a long-lived local checkout.) +> +> **Residual, not fixed here:** the stale `.gitignore` comment. `.gitignore:84` still reads +> `# Claude Code: settings.json is shared/tracked; settings.local.json is machine-local (never commit)`, +> contradicted by `/.claude/` at `:142`. It is a comment with no mechanical effect, and `.gitignore` is +> outside this lane's file list, so it is carried to the owner rather than edited: replace "settings.json +> is shared/tracked" with a note that the whole `/.claude/` tree is ignored by the private-paths block +> below. + **Related:** `.gitignore` (lines 128-146, 160), `scripts/security/scan_forbidden.py`, `scripts/dev/setup-leak-gate.ps1`, `.pre-commit-config.yaml`, `.github/workflows/ci.yml` (`noncode` at :472, pytest gate at :226), `tests/test_release_pipeline.py`, `tests/test_feature_map_claims.py`, `tests/test_scaffold.py`, `scripts/hooks/block-blanket-git-stage.ps1`, [`docs/SECURITY-DOCS-POLICY.md`](SECURITY-DOCS-POLICY.md), [`docs/SESSION-DRIFT-CONTROLS.md`](SESSION-DRIFT-CONTROLS.md), #321, #322. **Source:** public-repo disclosure audit, 2026-08-01. Verified against HEAD `12efbffc`; the audit flagged the finding as unconfirmed, and the absence of any such test/hook/gate is confirmed here. @@ -6446,7 +6506,19 @@ What was unwatched is the **release-lag window**: the interval between a fix lan **The repo already has the correct form, at scale.** `docs/BACKLOG.md` carries **44** citations shaped `[#52](archive/backlog/BACKLOG-CLOSED.md#52-corepoint-capability-parity-gaps--prioritized-roadmap-input-2026-06-27)`, and [`AOAG-DEPLOYMENT.md`](AOAG-DEPLOYMENT.md) does the same for `#100`/`#101`. So this is a §12 omission, not a missing convention. -**Fix shape.** Repoint the `#26` and `#27` markers at the archive. **Do not hand-write the fragment** — the archival pass generates the anchor, and a pointer with a wrong fragment is worse than one with none, because it looks precise and lands nowhere. Either derive it from the generator or cite the file without a fragment. A marker that must outlive its item is best served naming **both** locations (live now, archive after), which is what [`../CLAUDE.md`](../CLAUDE.md) §12's ISO 5055 marker was corrected to do. +**Fix shape.** Repoint the `#26` and `#27` markers at the archive. **Do not hand-write the fragment** — a pointer with a wrong fragment is worse than one with none, because it looks precise and lands nowhere. Derive it from the item's own heading text under GitHub's slug rule, or cite the file without a fragment. A marker that must outlive its item is best served naming **both** locations (live now, archive after), which is what [`../CLAUDE.md`](../CLAUDE.md) §12's ISO 5055 marker was corrected to do. + +> **CORRECTION 2026-08-10 (BACKLOG #1099) — the sentence above named tooling that does not exist.** +> It originally read *"the archival pass generates the anchor … Either derive it from the generator or +> cite the file without a fragment."* **There is no archival tooling in this repository**: closing an +> item is a manual move of its text from this file into +> [`archive/backlog/BACKLOG-CLOSED.md`](archive/backlog/BACKLOG-CLOSED.md), and the fragment is +> GitHub's heading slug, which nothing here generates. +> [`tests/test_link_resolution.py`](../tests/test_link_resolution.py) states the same thing from the +> other side — *"The move is manual — no script performs it — so there is nothing to fix upstream"* — +> and draws the conclusion this sentence pointed away from: a guard at the moment the item lands is +> the only thing that can catch the rot, because there is no generator to fix. Corrected rather than +> rewritten silently, since the block around it is closed record. **The near-miss is the reason this is worth a number.** The 5055 decline marker filed under #1073 cited only the live file, and would have rotted identically the moment #1073 archived — caught in review before merge. The session that wrote it had **already noticed** the #26/#27 staleness earlier that day and judged it not worth chasing, then reproduced it in a marker whose entire purpose is to outlive its item. A rot consciously declined is one you have stopped seeing well enough to avoid repeating. @@ -6507,7 +6579,7 @@ The two "No" rows are the majority and the harder half. Root cause for the ancho **Scope note — the non-markdown sweep has now RUN, and it found nothing.** The counts as filed read `*.md` only, and this note used to say citations in `harness/`, `ide/src/`, `messagefoundry/` and `.github/workflows/` "rot identically" and must be swept before the class could be called closed. That sweep ran 2026-08-07 across 1,219 tracked non-markdown files. It surfaced 68 distinct repo-relative paths that do not exist and **not one of them is a rotted citation**: they are test fixtures (`docs/adr/0001-collision.md`, `messagefoundry/x.py`, `tests/test_a.py`), targets inside withheld directories, or past-tense historical comments — `.github/workflows/release.yml:51` names the pre-cutover `scripts/publish/publish.ps1` under a heading reading "THIS GUARD WAS INVERTED UNTIL THE CUTOVER". The prediction was wrong, and the reason is worth keeping: citations in code are mostly written *about* the past, where prose citations are written as *pointers*. **Do not re-open this as unswept scope.** -**A gate now covers the catchable half, repo-wide.** `scripts/docs/link_check.py` with `tests/test_archive_link_resolution.py` asserts that every relative markdown link in the repository resolves — 5,327 links across 347 files. Widening it beyond the archive required removing three false-positive classes first, because a gate red on arrival gets suppressed rather than fixed: links inside inline code (a regex whose character class contains `](`, two VS Code `command:` URIs, and ADR 0160 quoting the link it records as removed), and the withheld `docs/releases/` and `.claude/`. **`.claude/` is the one to carry forward** — it is gitignored but *present* in a long-lived local checkout, so the first repo-wide measurement passed there and undercounted by 7; widening on that number would have put the gate red on CI's clean clone. A gate result is a fact about the configuration it ran in. The gate was then proved able to fail, by planting a break in a real document outside the archive and watching it go red. +**A gate now covers the catchable half, repo-wide.** `scripts/docs/link_check.py` with `tests/test_link_resolution.py` asserts that every relative markdown link in the repository resolves — 5,327 links across 347 files. Widening it beyond the archive required removing three false-positive classes first, because a gate red on arrival gets suppressed rather than fixed: links inside inline code (a regex whose character class contains `](`, two VS Code `command:` URIs, and ADR 0160 quoting the link it records as removed), and the withheld `docs/releases/` and `.claude/`. **`.claude/` is the one to carry forward** — it is gitignored but *present* in a long-lived local checkout, so the first repo-wide measurement passed there and undercounted by 7; widening on that number would have put the gate red on CI's clean clone. A gate result is a fact about the configuration it ran in. The gate was then proved able to fail, by planting a break in a real document outside the archive and watching it go red. **Related:** #1094 (two instances of this class, closed — already satisfied when filed), #1073 (whose §12 marker prompted the original find, and which is itself the closed-but-not-archived case), #1000 (a control whose green is not evidence about what it appears to cover), #1099 (#1094's claim that an archival pass *generates* the anchor, describing tooling that does not exist), #1100 (the nine sites whose citing claim is dead, split out of this item rather than repointed), [`../CLAUDE.md`](../CLAUDE.md) §11 (state a load-bearing fact once and link to it — which is what makes a link's durability load-bearing). @@ -6677,23 +6749,32 @@ contention; recorded in the #1095 handoff note rather than lost. ## 1099. BACKLOG #1094 describes an archival pass that generates anchors; no archival tooling exists -> 🔢 **Filed 2026-08-07 - not started.** Value **4/10** · Difficulty **1/10**. #1094 states that "the archival -> pass generates the anchor". There is **no archival tooling in the repository at all** - closing an -> item is a manual move of its text from [`BACKLOG.md`](BACKLOG.md) into -> [`archive/backlog/BACKLOG-CLOSED.md`](archive/backlog/BACKLOG-CLOSED.md). That sentence describes a -> generator that was never built. +> ✅ **Closed 2026-08-10 — the sentence is corrected in place, and the absence was re-confirmed by search rather than inherited.** Value **4/10** · Difficulty **1/10**. #1094's *"the archival pass generates the anchor … derive it from the generator"* now carries a dated `CORRECTION` blockquote naming the manual move and GitHub's heading slug, left as a marked correction rather than a silent rewrite because the block is closed record. Filed 2026-08-07. **Cluster:** Documentation record / instrument accuracy. **Priority:** P3. **Verdict:** build (a prose correction). **Severity:** no product effect. -**Why this is not pedantry.** #1094 is closed, so the sentence now sits in the archive as settled -record, and it points maintenance at the wrong place: it implies the fix for anchor rot belongs in a -tool, when the only thing that can catch it is a gate at the moment the item lands - which is exactly -the reasoning `tests/test_archive_link_resolution.py` +**Why this is not pedantry.** The sentence points maintenance at the wrong place: it implies the fix +for anchor rot belongs in a tool, when the only thing that can catch it is a gate at the moment the +item lands - which is exactly the reasoning [`tests/test_link_resolution.py`](../tests/test_link_resolution.py) records. A future reader looking for the generator to fix will not find one. -**The work.** Correct the sentence in place in the archive, marked as a correction rather than a -silent rewrite, since the surrounding text is a closed record. +**The absence, re-measured 2026-08-10 rather than quoted.** `git ls-files | grep -iE archiv` returns +**seven** paths and every one is a document — `docs/archive/backlog/BACKLOG-CLOSED.md` and six under +`docs/archive/throughput/`. No script, no CI job, no hook. The independent corroboration is the gate's +own docstring: *"The move is manual — no script performs it — so there is nothing to fix upstream."* + +**TWO CORRECTIONS TO THIS ITEM'S OWN TEXT, both the class it was filed about.** + +- It said the sentence *"now sits in the archive"*. It did not — #1094 was closed-in-live, still in + [`BACKLOG.md`](BACKLOG.md), and the correction was therefore applied there. It travels into the + archive with #1094's block in the same change. +- It cited `tests/test_archive_link_resolution.py`, **which is on no merged ref.** PR #281 squash-merged + as `6cb34f5f` and the file landed as `tests/test_link_resolution.py`; the pre-squash name survives + only on the stale local branch `refs/heads/pr281`. An item about a citation naming a thing that does + not exist cited a thing that does not exist. Found by searching every ref, not by trusting the string + — the same instrument the file's own Ledger erratum prescribes. The identical stale name in #1095's + block was corrected with it. **Related:** #1094, #1095 (the repo-scale instance of the same class), #1000. @@ -8716,7 +8797,7 @@ filing. ## 1200. the CI docs-only detector exempts EXECUTABLE files under `docs/` from the entire suite -> 🔢 **Filed 2026-08-09 - FIXED in the same change. Reproduced with the workflow's own regex under real `grep -E`, with a negative control.** Value **7/10** · Difficulty **2/10**. `ci.yml`'s `changes` job short-circuits the required `test` legs when every changed path is docs-only. `^docs/` is an alternation branch in that allowlist, so it matches a **`.py` under `docs/`** and short-circuits before the stated `*.py` rule is ever reached. A PR touching only such a file set `code=false` and skipped install, lint, type-check and the whole of pytest. +> ✅ **CLOSED 2026-08-10 — confirmed by reading the shipped workflow, not the commit message.** `.github/workflows/ci.yml` carries `alwayscode='\.(py|ps1|sh|ts|js|yml|yaml|toml|lock|cfg|ini)$'` and evaluates it in the FIRST `elif`, ahead of both `alwayscodepath` and `noncode`. Re-driven 2026-08-10 with the regexes read back out of `ci.yml`: `docs/security/asvs-apply-cells.py` -> code, `docs/SECURITY.md` -> NON-CODE, `.gitignore` -> code. `tests/test_ci_docs_only_detector.py` 23 passed. Filed 2026-08-09 - FIXED in the same change. Value **7/10** · Difficulty **2/10**. `ci.yml`'s `changes` job short-circuits the required `test` legs when every changed path is docs-only. `^docs/` is an alternation branch in that allowlist, so it matches a **`.py` under `docs/`** and short-circuits before the stated `*.py` rule is ever reached. A PR touching only such a file set `code=false` and skipped install, lint, type-check and the whole of pytest. **Cluster:** CI correctness / gate blindness. **Priority:** P2. **Verdict:** build (done). **Severity:** no product effect and no PHI effect. The cost is that a defect here does not fail loudly @@ -8774,7 +8855,7 @@ sibling work), and escalated from instance to class by the parallel `asvs-tracki which measured the blast radius in both repos and identified the `#327` precedent. ## 1201. `redacted_settings` served credential-bearing HTTP headers outside a five-name list -> 🔢 **Filed 2026-08-09 - FIXED IN THE SAME CHANGE, and the entry is published WITH the fix rather than ahead of it.** Value **8/10** · Difficulty **2/10**. Header redaction was `str(k).lower() in _SECRET_HEADER_NAMES` -- an exact-membership test against **five** strings (`authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `cookie`). Header names are **operator-authored free text**, typed into `connections.toml` or a Handler, so an exhaustive list cannot exist even in principle. Measured against the shipped list: `X-Auth-Token`, `X-Amz-Security-Token` and `Private-Token` were all returned VERBATIM. +> ✅ **CLOSED 2026-08-10 — confirmed in the shipped code, not from the report.** `messagefoundry/config/wiring.py` `_is_secret_header()` now ends `return any(tok in low for tok in _SECRET_HEADER_SUBSTRINGS)` over `auth|token|secret|credential|password|passphrase|key`, with `_SECRET_HEADER_NAMES` kept as an explicit floor (`cookie` matches no substring rule), a `_NOT_SECRET_HEADER_SUFFIXES` exclusion, and a second VALUE arm (`_looks_like_a_credential_value`: RFC 7235 scheme prefixes + JWT shape) for opaque vendor names. `tests/test_connection_factory_redaction_domain.py` 58 passed. **The route-onward below is NOT closed by this** - see the residual. Filed 2026-08-09 - FIXED IN THE SAME CHANGE, and the entry is published WITH the fix rather than ahead of it. Value **8/10** · Difficulty **2/10**. Header redaction was `str(k).lower() in _SECRET_HEADER_NAMES` -- an exact-membership test against **five** strings (`authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `cookie`). Header names are **operator-authored free text**, typed into `connections.toml` or a Handler, so an exhaustive list cannot exist even in principle. Measured against the shipped list: `X-Auth-Token`, `X-Amz-Security-Token` and `Private-Token` were all returned VERBATIM. **Cluster:** Security / secret disclosure. **Priority:** P1. **Verdict:** build (done). **Severity:** on a first deployment, an operator who configured an outbound connection with a bearer @@ -8815,11 +8896,19 @@ method: the defect class is *a control whose domain is narrower than its surface the next instance is to ask which other control quantifies over a domain it does not derive. `tests/test_connection_factory_redaction_domain.py` now covers both. -**Route onward, NOT closed by this.** The shape rule is a heuristic over a free-text domain, so it is a -floor and not a proof: a header named without any of those substrings (`X-Shared-Signature`, a -vendor-specific opaque name) still passes. The durable fix is for the header value to never reach a -serializer resolved -- the `env()`-only treatment `body_secret_value_*` already gets -- and that is a -larger change than this one. +**Route onward, NOT closed by this — PENDING OWNER LEDGER DECISION (G28).** The shape rule is a heuristic +over a free-text domain, so it is a floor and not a proof: a header named without any of those substrings +(`X-Shared-Signature`, a vendor-specific opaque name) still passes the NAME arm. The durable fix is for the +header value to never reach a serializer resolved -- the `env()`-only treatment `body_secret_value_*` +already gets -- and that is a larger change than this one. + +> **Residual carried forward 2026-08-10, deliberately un-numbered.** Closing this item closes the +> five-name membership defect; it does **not** close the route-onward above. Whether that residual becomes +> its own backlog number, folds into #1206's sibling residual (both are the same *"nested/free-text values +> are never `env()`-resolved"* shape), or is accepted as-is **is the owner's call, not the archiver's** — +> so no number was allocated for it here. The mitigation actually shipped is the second (VALUE) arm of +> `_is_secret_header`, which catches an opaque-named header carrying a `Bearer`/`Basic`/JWT value; a header +> both opaquely named *and* opaquely valued remains outside both arms by construction. **Source:** found 2026-08-09 while probing for a second instance of the `#1106` class before building a generalised check, on the reasoning that a meta-check built from one instance is shaped like that @@ -8827,7 +8916,7 @@ instance. Two domains were probed; this one leaked. ## 1206. `redacted_settings` served ODBC driver credentials sitting in `odbc_params` -> 🔢 **Filed 2026-08-09 - FIXED IN THE SAME CHANGE, entry published WITH the fix.** Value **8/10** · Difficulty **3/10**. `redacted_settings` masks flat scalars and descended into `headers` alone, so a credential inside `odbc_params` was returned VERBATIM by `GET /connections/{name}/metadata` behind `MONITORING_READ` and printed by `graph --json` - on the SAME object whose top-level `password` masked correctly. +> ✅ **CLOSED 2026-08-10 — confirmed in the shipped code, not from the report.** `messagefoundry/config/wiring.py` `redacted_settings()` now carries an `elif name == "odbc_params" and isinstance(value, dict)` arm emitting `{k: ("***" if _is_secret_odbc_key(k) else v) ...}`, and `_is_secret_odbc_key()` is shape-based and case-insensitive over `pwd|password|passwd|secret|token|credential|passphrase`, with `_NOT_SECRET_ODBC_KEYS` keeping the libpq PATH keywords (`sslkey`/`sslcert`/`sslrootcert`/`sslcrl`) readable. `display_settings` inherits it by delegation. **This is a DISPLAY fix; the storage residual is NOT closed** - see below. Filed 2026-08-09 - FIXED IN THE SAME CHANGE, entry published WITH the fix. Value **8/10** · Difficulty **3/10**. `redacted_settings` masks flat scalars and descended into `headers` alone, so a credential inside `odbc_params` was returned VERBATIM by `GET /connections/{name}/metadata` behind `MONITORING_READ` and printed by `graph --json` - on the SAME object whose top-level `password` masked correctly. **Cluster:** Security / secret disclosure. **Priority:** P1. **Verdict:** build (done). **Severity:** on a first deployment, an ODBC driver password would be served to any monitoring reader @@ -8851,10 +8940,19 @@ AFTER : PWD, sslpassword, Password -> '***' the only expressible shape. **A refusal that removes the SAFE expression while leaving the UNSAFE one is not a mitigation.** -**THIS IS A DISPLAY FIX, NOT A STORAGE FIX** - stated because the difference matters and is easy to -lose. The credential remains an inline literal in the config file. Keeping it out of the file needs -`env()` to work here, which needs nested settings to be env-resolved. That changes the resolution path -and what the refusal above means, so it is the **route-onward** and is deliberately not folded in. +**THIS IS A DISPLAY FIX, NOT A STORAGE FIX — and the storage half is PENDING OWNER LEDGER DECISION +(G28).** Stated because the difference matters and is easy to lose. The credential remains an inline +literal in the config file. Keeping it out of the file needs `env()` to work here, which needs nested +settings to be env-resolved. That changes the resolution path and what `_reject_envref_odbc_params` +means, so it is the **route-onward** and is deliberately not folded in. + +> **Residual carried forward 2026-08-10, deliberately un-numbered.** `env()` resolution inside nested +> settings is the sibling of #1201's route-onward — the same *"a value inside a container is never +> `env()`-resolved, so the safe expression does not exist there"* shape, which is why they are named +> together rather than separately. Whether this earns its own number, merges with #1201's, or is accepted +> is the **owner's decision**; no number was allocated for it here, and it is not being quietly closed as +> prose. What IS closed is the disclosure: on a first deployment the value would no longer reach +> `/metadata` or `graph --json`. **A THIRD PREDICATE, AND THE FIRST ATTEMPT PROVES WHY.** I reached for `_is_secret_setting` - and it returns False for every one of `PWD`, `Password` and `sslpassword`, because it matches a fixed @@ -8890,7 +8988,7 @@ guard written after the previous instance picked a domain narrower than the surf ## 1207. an `env()` ref in a headers table, and a credential in URL userinfo, both escaped redaction -> 🔢 **Filed 2026-08-09 - FIXED IN THE SAME CHANGE.** Value **7/10** · Difficulty **2/10**. Two holes, both INSIDE surfaces the redactor already claimed to handle. **(b)** the `headers` branch had no `EnvRef` arm, so an `env()` ref in a headers table came back as the RAW object carrying its `default` intact - while the same `env()` on a top-level credential correctly emits `{"env": key}` with the default dropped. **(c)** `url="https://user:SECRET@host"` was returned verbatim by both serializers while `proxy_password` on the SAME object masked. +> ✅ **CLOSED 2026-08-10 — both arms confirmed in the shipped code, not from the report.** `messagefoundry/config/wiring.py`: `_redact_header_value()` opens `if isinstance(value, EnvRef): return {"env": value.key}` — the default dropped for EVERY header, not only credential-shaped ones — and `_mask_url_userinfo()` returns `f"{scheme}//{user}:***@{hostpart}"`, wired into `redacted_settings()` by `elif isinstance(value, str) and name.lower().endswith(_URL_SETTING_SUFFIXES)`, with `_URL_SETTING_SUFFIXES` a NAME set plus suffix rule so bare `proxy_url` is covered. Both reach `display_settings` by delegation. Filed 2026-08-09 - FIXED IN THE SAME CHANGE. Value **7/10** · Difficulty **2/10**. Two holes, both INSIDE surfaces the redactor already claimed to handle. **(b)** the `headers` branch had no `EnvRef` arm, so an `env()` ref in a headers table came back as the RAW object carrying its `default` intact - while the same `env()` on a top-level credential correctly emits `{"env": key}` with the default dropped. **(c)** `url="https://user:SECRET@host"` was returned verbatim by both serializers while `proxy_password` on the SAME object masked. **Cluster:** Security / secret disclosure. **Priority:** P1. **Verdict:** build (done). **Severity:** on a first deployment, both would be served to any `MONITORING_READ` caller and printed @@ -8983,7 +9081,7 @@ became the third instance: *"that is not a coincidence to note in a residual; it rename boundary itself needs a guard"*. Filed before it was forgotten, per that session's request. ## 1209. the dependency advisory guard inverts to FAIL-OPEN when the advisory API errors -> 🔢 **Filed 2026-08-09 - FIXED IN THE SAME CHANGE, entry published WITH the fix.** Value **9/10** · Difficulty **2/10**. Guardrail #2 of `dependabot-auto-merge.yml` reads `count="$(gh api ... || echo "ERR")"`. The `||` runs INSIDE the command substitution, so it APPENDS to stdout rather than replacing it - and `gh api` copies the JSON error BODY to stdout on any HTTP error. The sentinel `[ "$count" = "ERR" ]` therefore misses, and the guard emits `advisory_ok=true` for a lookup that never succeeded. +> ✅ **CLOSED 2026-08-10 — confirmed in the shipped workflow AND re-executed against a `gh` stub.** `.github/workflows/dependabot-auto-merge.yml` now reads `--jq '[.[] | select(.withdrawn_at == null)] | length' 2>/dev/null)" || count="ERR"` — the `||` binds the ASSIGNMENT, outside the substitution — followed by the shape test `case "$count" in ""|*[!0-9]*)`. Re-run 2026-08-10 under `bash -e` with a stub reproducing the stream split (JSON body to stdout, `gh:` line to stderr, exit 1): the pre-fix form (`|| echo "ERR"` inside + equality sentinel) leaves `count={"message":"API rate limit exceeded",...}ERR`, misses the sentinel, errors "integer expression expected" and emits **advisory_ok=true**; the shipped form leaves `count=ERR` and emits **advisory_ok=false**. Filed 2026-08-09 - FIXED IN THE SAME CHANGE, entry published WITH the fix. Value **9/10** · Difficulty **2/10**. Guardrail #2 of `dependabot-auto-merge.yml` read `count="$(gh api ... || echo "ERR")"`. The `||` runs INSIDE the command substitution, so it APPENDS to stdout rather than replacing it - and `gh api` copies the JSON error BODY to stdout on any HTTP error. The sentinel `[ "$count" = "ERR" ]` therefore misses, and the guard emits `advisory_ok=true` for a lookup that never succeeded. **Cluster:** CI / supply chain. **Priority:** P1. **Verdict:** build (done). **Severity:** unlike the redaction items above, this is not conditional on a first deployment - the @@ -9017,6 +9115,15 @@ fixed advisory_ok=false advisory_ok=true <- fails closed, hap **A stub that merely exits non-zero would have proved nothing** - it would pass against the defective code too. The defect is that the BODY reached the variable, so the stub has to write the body. +**Where that test actually runs, stated because a skip is not a pass.** The three +`test_the_advisory_guard_fails_closed_when_the_api_errors` rows execute the shipped `run:` body and +therefore need `bash` **and `jq`**. On the maintainer's box Git Bash ships no `jq`, so all three +**SKIP** locally and the file reports `27 passed, 7 skipped` - a green local run that has not exercised +this guard at all. They run on the ubuntu leg and on the two required `windows-2022`/`windows-2025` +legs, whose images carry `jq`. The 2026-08-10 closure therefore did not rest on that local green: the +pre-fix and shipped guards were re-executed by hand under `bash -e` against a body-writing stub, which +needs no `jq`. + **The comment directly above the defect asserted the opposite:** "Fail closed on any error", and the header, "a rate-limit/API error or no-matching-advisory routes to manual review, never auto-merge." A compensating control resting on a false premise, which is the shape SDS-3.7 names. diff --git a/docs/SESSION-DRIFT-CONTROLS.md b/docs/SESSION-DRIFT-CONTROLS.md index 25377f71..b49ed080 100644 --- a/docs/SESSION-DRIFT-CONTROLS.md +++ b/docs/SESSION-DRIFT-CONTROLS.md @@ -66,9 +66,21 @@ The gate's own docstring records that 29% of Edit/Write calls came from a sessio that wrote *correctly* into a worktree by absolute path; a cwd-keyed gate would have denied all of them. Rule 2 is the sole exception, and that exception is the source of the ultracode friction in §4. -**[`scripts/hooks/block-blanket-git-stage.ps1`](../scripts/hooks/block-blanket-git-stage.ps1)** (project -scope, [`.claude/settings.json`](../.claude/settings.json)) refuses blanket `git add -A`/`.`/`-u` and -`git commit -a`, so two sessions in one tree can't sweep each other's files into one commit. +**[`scripts/hooks/block-blanket-git-stage.ps1`](../scripts/hooks/block-blanket-git-stage.ps1)** refuses +blanket `git add -A`/`.`/`-u` and `git commit -a`, so two sessions in one tree can't sweep each other's +files into one commit. + +**It does not travel, and this paragraph used to imply it did** (BACKLOG #327). The script is tracked, +but the `PreToolUse` matcher that invokes it is project-scope `.claude/settings.json` — untracked, under +`.gitignore`'s `/.claude/` rule — so a fresh clone and every `git worktree add` come up without it. It is +a local Claude Code session control, fail-open by design: real and useful inside a configured session, +and not repo-wide coverage. The publishing boundary it was cited alongside is asserted independently, by +[`tests/test_private_paths_stay_ignored.py`](../tests/test_private_paths_stay_ignored.py) in CI. + +*(The link to that settings file was removed rather than repaired — it named a path no reader outside the +maintainer's own machine has. `scripts/docs/link_check.py` could not have caught it: `.claude/` is in +that script's `WITHHELD` set, so such an href is skipped before it is even counted. Measured 2026-08-10 — +an href to a missing non-withheld path fails the check, the same href under `.claude/` does not.)* ### Detection — `SessionStart` hooks From 5a9dcd9c3883af98280287f2ce3b11a0120cf35b Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 09:51:51 -0500 Subject: [PATCH 06/28] fix(vault): refuse redirects on all three [vault] clients (BACKLOG #1042) Every other shipped HTTP egress routes through a no-redirect opener -- transports/rest.py's _NO_REDIRECT_OPENER, and auth/oidc_http.py's local twin for the IdP hop. The [vault] provider clients were the exception: they built an hvac.Client with no redirect policy, and hvac's default is allow_redirects=True. The token rides as an X-Vault-Token header on every Transit/KV call, so on a first deployment a 3xx from an on-path attacker (absent TLS integrity) or a spoofed Vault would relocate a request carrying it, while every default egress refused the same redirect. Set allow_redirects=False at both client-construction points. The third client named in the item, store/crypto_transit.py, already reuses keyprovider_vault's _build_client, so it inherits the policy from one construction point rather than growing a second. Verified against the real library rather than assumed -- hvac 2.4.0's Client.__init__ takes allow_redirects (default True), stores it on the adapter, and the adapter passes allow_redirects=self.allow_redirects to requests.Session.request. hvac is the optional [vault] extra and CI never installs it, so the committed tests stand a recording module in for it and assert what our code asks for; the measurement above is recorded in the test module's docstring because no test can reach it without the extra. Watched all three RED against the pre-fix code -- each reported the constructed kwargs as dict_keys(['url', 'token']), no redirect policy present. The Transit cipher is driven end to end through build_transit_cipher rather than asserted by identity, so a future private client construction there reds this test. A fourth test is the live positive control on the instrument: it builds a client without the policy through the same fake and asserts the recorder reports its absence, so a green above cannot be the fake swallowing an unrecognised kwarg. --- messagefoundry/config/secretprovider_vault.py | 7 +- messagefoundry/store/keyprovider_vault.py | 11 +- tests/test_vault_client_redirect_policy.py | 141 ++++++++++++++++++ 3 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 tests/test_vault_client_redirect_policy.py diff --git a/messagefoundry/config/secretprovider_vault.py b/messagefoundry/config/secretprovider_vault.py index f25fa330..ffabb92d 100644 --- a/messagefoundry/config/secretprovider_vault.py +++ b/messagefoundry/config/secretprovider_vault.py @@ -72,7 +72,12 @@ def _build_client(addr: str | None, token: str | None) -> Any: Vault. ``addr``/``token`` pass through; when ``None``, hvac falls back to its own VAULT_ADDR/VAULT_TOKEN environment conventions.""" hvac = _import_hvac() - client: Any = hvac.Client(url=addr, token=token) + # allow_redirects=False (BACKLOG #1042, ASVS 15.3.2/1.3.6): hvac's default is True, and this was + # one of the three clients that did not carry the no-redirect policy every other shipped HTTP + # egress does. `token` rides as an `X-Vault-Token` header on every KV read, so a 3xx from an + # on-path attacker (absent TLS integrity) or a spoofed Vault would otherwise relocate the + # request carrying it. See store/keyprovider_vault.py's twin for the measurement. + client: Any = hvac.Client(url=addr, token=token, allow_redirects=False) return client diff --git a/messagefoundry/store/keyprovider_vault.py b/messagefoundry/store/keyprovider_vault.py index fcd89baa..e7737bb4 100644 --- a/messagefoundry/store/keyprovider_vault.py +++ b/messagefoundry/store/keyprovider_vault.py @@ -85,7 +85,16 @@ def _build_client(addr: str | None, token: str | None) -> Any: enforce_outbound_length_limits(addr or "", {"X-Vault-Token": token} if token else {}) # hvac.Client() reads VAULT_ADDR/VAULT_TOKEN from the environment when url/token are None. - client: Any = hvac.Client(url=addr, token=token) + # + # allow_redirects=False (BACKLOG #1042, ASVS 15.3.2/1.3.6): every other shipped HTTP egress + # refuses redirects (transports/rest.py's _NO_REDIRECT_OPENER, auth/oidc_http.py's local twin), + # and this client was the exception -- hvac's default is True. `token` rides as an + # `X-Vault-Token` header on EVERY Transit call, so a 3xx from an on-path attacker (absent TLS + # integrity) or a spoofed Vault would otherwise relocate the request, and requests re-sends the + # header on a same-host redirect. Measured against hvac 2.4.0: the kwarg lands on the adapter, + # which passes it to requests.Session.request. Shared with crypto_transit.py, so the Transit + # cipher inherits the policy from this one construction point. + client: Any = hvac.Client(url=addr, token=token, allow_redirects=False) return client diff --git a/tests/test_vault_client_redirect_policy.py b/tests/test_vault_client_redirect_policy.py new file mode 100644 index 00000000..25077587 --- /dev/null +++ b/tests/test_vault_client_redirect_policy.py @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""Every `[vault]` client refuses redirects (BACKLOG #1042, ASVS 15.3.2 / 1.3.6). + +Every other shipped HTTP egress routes through a no-redirect opener (`transports/rest.py`'s +`_NO_REDIRECT_OPENER`, and `auth/oidc_http.py`'s local twin for the IdP hop). The three `[vault]` +clients were the exception: they built an `hvac.Client` with no redirect policy, and hvac's default +is `allow_redirects=True` -- so on a first deployment an on-path 3xx (absent TLS integrity) or a +spoofed Vault could relocate a request carrying `X-Vault-Token` off-path. + +**Why the fake, and what it does and does not prove.** `hvac` is the optional `[vault]` extra and CI +never installs it, so these tests stand a recording module in for it and assert what OUR code asks +for. That the request honours the ask was verified against the real library rather than assumed: +hvac 2.4.0's `Client.__init__` takes `allow_redirects` (defaulting to `True`), stores it on the +adapter, and the adapter passes `allow_redirects=self.allow_redirects` to `requests.Session.request` +-- measured 2026-08-10, and recorded here because a test cannot reach it in an environment without +the extra. +""" + +from __future__ import annotations + +import sys +import types +from typing import Any + +import pytest + + +class _RecordingHvac: + """A stand-in `hvac` module that records the kwargs each `Client(...)` was constructed with.""" + + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def Client(self, **kwargs: Any) -> Any: # noqa: N802 — mirrors hvac's own class name + self.calls.append(kwargs) + return _FakeVaultClient() + + +class _FakeTransit: + def read_key(self, *, name: str) -> dict[str, Any]: + return {"data": {"name": name}} + + +class _FakeSecrets: + def __init__(self) -> None: + self.transit = _FakeTransit() + + +class _FakeVaultClient: + def __init__(self) -> None: + self.secrets = _FakeSecrets() + + +def _install_fake_hvac(monkeypatch: pytest.MonkeyPatch) -> _RecordingHvac: + """Put a recording `hvac` in `sys.modules` so the lazy `import hvac` inside each provider + resolves to it. Returns the recorder.""" + recorder = _RecordingHvac() + module = types.ModuleType("hvac") + module.Client = recorder.Client # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "hvac", module) + return recorder + + +# --- the two client factories ---------------------------------------------------------------- + + +def test_store_key_provider_client_refuses_redirects(monkeypatch: pytest.MonkeyPatch) -> None: + """`store/keyprovider_vault.py` `_build_client` -- the KEK-unwrap client, and the one + `store/crypto_transit.py` reuses. + + Mutation: drop `allow_redirects=False` from the `hvac.Client(...)` call. Red: the assertion + reports the constructed kwargs, which then carry no redirect policy at all.""" + from messagefoundry.store import keyprovider_vault + + recorder = _install_fake_hvac(monkeypatch) + keyprovider_vault._build_client("https://vault.example:8200", "s3cr3t-token") + + assert len(recorder.calls) == 1, "the provider must construct exactly one client" + assert recorder.calls[0].get("allow_redirects") is False, ( + f"the store KEK client must refuse redirects; it was built with {recorder.calls[0].keys()}" + ) + + +def test_secret_provider_client_refuses_redirects(monkeypatch: pytest.MonkeyPatch) -> None: + """`config/secretprovider_vault.py` `_build_client` -- the KV v2 connector-credential client. + + Mutation: drop `allow_redirects=False`. Red: the assertion reports the constructed kwargs.""" + from messagefoundry.config import secretprovider_vault + + recorder = _install_fake_hvac(monkeypatch) + secretprovider_vault._build_client("https://vault.example:8200", "s3cr3t-token") + + assert len(recorder.calls) == 1, "the provider must construct exactly one client" + assert recorder.calls[0].get("allow_redirects") is False, ( + f"the KV secret client must refuse redirects; it was built with {recorder.calls[0].keys()}" + ) + + +def test_transit_cipher_client_refuses_redirects(monkeypatch: pytest.MonkeyPatch) -> None: + """The third client, driven END TO END rather than by inspection: `build_transit_cipher` is the + real entry point, and it is what proves the Transit cipher inherits the policy instead of + quietly growing its own client construction. + + Mutation: give `crypto_transit` its own `hvac.Client(...)` call without the policy. Red: this + test, where an identity assertion against `keyprovider_vault._build_client` would not + necessarily be.""" + from messagefoundry.config.settings import StoreSettings + from messagefoundry.store import crypto_transit + + monkeypatch.setenv("MEFOR_STORE_TRANSIT_KEY", "mefor-store-dek") + monkeypatch.setenv("MEFOR_STORE_VAULT_ADDR", "https://vault.example:8200") + monkeypatch.setenv("MEFOR_STORE_VAULT_TOKEN", "s3cr3t-token") + monkeypatch.delenv("MEFOR_STORE_TRANSIT_AUDIT_KEY", raising=False) + recorder = _install_fake_hvac(monkeypatch) + + crypto_transit.build_transit_cipher(StoreSettings()) + + assert len(recorder.calls) == 1, "the Transit cipher must construct exactly one client" + assert recorder.calls[0].get("allow_redirects") is False, ( + f"the Transit cipher client must refuse redirects; it was built with " + f"{recorder.calls[0].keys()}" + ) + + +# --- the guard is not vacuous ------------------------------------------------------------------ + + +def test_the_recorder_would_see_a_missing_policy(monkeypatch: pytest.MonkeyPatch) -> None: + """Live positive control for the three assertions above: the recorder reports a client built + WITHOUT the policy as `None`, so a green above is a statement about the shipped call and not an + artifact of the fake swallowing kwargs it does not recognise.""" + recorder = _install_fake_hvac(monkeypatch) + import hvac # noqa: PLC0415 — resolves to the fake installed just above + + hvac.Client(url="https://vault.example:8200", token="t") # type: ignore[attr-defined] + + assert recorder.calls[0].get("allow_redirects") is None + assert recorder.calls[0]["url"] == "https://vault.example:8200", ( + "the recorder must capture the kwargs verbatim, or the assertions above measure nothing" + ) From fbd217848e3b58e6871e6bb7f173c6d5b4e40f2d Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 09:54:37 -0500 Subject: [PATCH 07/28] backlog(archive): move 41 closed items out of the live ledger, verbatim (BACKLOG #327, #1099, #1200, #1201, #1206, #1207, #1209) Every item carrying a closed banner in docs/BACKLOG.md moves into docs/archive/backlog/BACKLOG-CLOSED.md: the 34 that were already closed-in-live plus the 7 closed in the preceding commit. live 282 -> 241 archive 195 -> 236 total 477, unchanged VERBATIM, and measured rather than asserted. Each block was SLICED, never re-rendered, so headings are byte-identical by construction -- 41/41 confirmed against the pre-move file, which is what keeps every #- anchor resolving. Undoing the link rewrite reproduces the pre-move body exactly for 41/41. No item was lost (the pre-move set minus live minus archive is empty) and no number is duplicated across the two files. DEPTH IS +2, NOT +1. docs/BACKLOG.md -> docs/archive/backlog/BACKLOG-CLOSED.md crosses archive/ AND backlog/, so 84 relative hrefs took a ../../ prefix. That is what the existing archive already uses (../../adr/... for docs/'s adr/...), and the +1 form the plan called for is provably wrong: injecting one href at +1 takes link_check.py RED, resolving to docs/archive/adr/0004-payload-agnostic-ingress.md, which does not exist. Restored, green again -- the gate can see this class. The rewrite replicates link_check.py's OWN skip rules (fenced blocks; links whose "]" falls inside an inline code span), so the set of hrefs rewritten is exactly the set the gate checks. Rewriting more would corrupt displayed text; rewriting fewer would leave a checked link broken. Appended rather than inserted in numeric order, which is the archive's existing convention -- its tail already runs 348, 349, 350, 335, 233, 326. Gates: backlog_status_check 477 items (241 live + 236 archive), each declaring exactly one status; link_check 5369 links across 347 files, all resolving; ledger_check clean. --- docs/BACKLOG.md | 1731 +---------------------- docs/archive/backlog/BACKLOG-CLOSED.md | 1790 ++++++++++++++++++++++++ 2 files changed, 1803 insertions(+), 1718 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 839c3a0c..12e57cd4 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -2441,28 +2441,6 @@ lane; demand-gated on a first enterprise Windows/AD deployment. --- -## 228. Steps / config search finds handlers, routers, and transforms by name (not just connections) - -> ✅ **CLOSED 2026-08-05 — both 2026-07-28 remainders built.** Value **4/10** · Difficulty **2/10**. **(a)** Definitions rows now carry a `contextValue` of their own — `meforSymbolHandler` on a handler row — gating the inline **View as Steps** action, which resolves through the row's *file*. They deliberately do **not** borrow `graphModel`'s `meforElementHandler` / `meforElement`, and no row claims an `elementKind` / `elementName`: a row's name is the Python **function** name (`def handle`) while the graph is keyed by the registered **decorator** name (`@handler("acme_adt_handler")`), and every `samples/config/` module makes the two differ — so the element vocabulary would render a **Show in Wiring Map** action that could only ever land on "the focused element no longer exists in the graph". Router / transform / send rows carry no action. **(b)** `SymbolKind` gains `send`: a separate extraction pass (a `Send(…)` sits inside a def body, out of reach of the column-0 def regex) indexes the connection each call addresses, at the call-site line; its comment guard is quote-aware, so a *trailing* `# was Send("OB_OLD", …)` is not a call site while a `#` inside a string literal does not truncate the line. **This is a bound, not a completeness claim:** at least quoted-literal targets and module-level `NAME = "literal"` constants are indexed; at least a computed, imported, or f-string target — and a ruff-wrapped call whose target is not on the `Send(` line — is dropped rather than guessed. **That is NOT the `graph --json` bound:** that extractor marks an unresolvable target `dynamic` and *surfaces* it ([ADR 0091](adr/0091-element-centric-connections-view.md) AC-3), and its module-constant rule validates against the whole module, neither of which this flat text scan does. The graph views remain the authority on resolved wiring. Twenty-one new tests, all node-side (so they run on every `ide` CI leg, not only the Windows Extension Host leg), each falsified. - -> **AMENDED 2026-08-05 — both remainders described below are now BUILT; the 2026-07-28 block that follows is the historical record, not current state.** Read it as the finding that scoped this work, not as a live gap. The one correction worth carrying forward: remainder (a)'s diagnosis named `viewItem == meforElementHandler` as the gate to satisfy, and adopting that value verbatim is precisely what the close had to avoid — see the CLOSED banner above. - -> **AMENDED 2026-07-28 — the index IS built; two clauses of the Proposed line are not.** Adversarial verification refuted a full close. **BUILT:** `ide/src/symbolIndex.ts` scans and surfaces handlers / routers / transforms **by name** in the MEFOR view's Definitions section, unit-tested — which fixes the item's headline complaint (a transform is a Python symbol inside a file named for the *connection*, so neither the sidebar search nor Ctrl+P could find it). -> -> ⚠️ **REMAINDER (a): a hit cannot open straight into the Steps view.** The Proposed line asks to reuse the CodeLens / `openSteps` entry point, but Definitions rows carry **no `contextValue`**, so the inline "View as Steps" action — gated on `viewItem == meforElementHandler` — never renders on them, and a click runs plain `openSource`. ⚠️ **REMAINDER (b): "(and the outbound connections a handler sends to)" is outside the index** — `SymbolKind` is `handler|router|transform` only, and the definition regex matches **top-level `def`** only. Both are small; neither is done. - -**Cluster:** IDE & Authoring. **Priority:** P2. **Verdict:** build. **Severity:** low. - -**What:** the MessageFoundry sidebar search (the box over the MESSAGEFOUNDRY view) matches **connection** names only. Searching for a **handler / router / transform** name — e.g. `xform_SITEA_to_erp_mfn` — returns “No matching results” even though that handler exists (it is defined inside `IB_FILE_HR_Materials_SITEA_MFN.py`, a role-combined feed module whose *filename* is the connection, not the handler). VS Code's own `Ctrl+P` also misses it, because the name is a symbol inside a file, not a filename. - -**Why:** operators think in terms of the **transform / message name**, not the feed file it happens to live in. The connection→router→handler wiring is a graph (CLAUDE.md §1), so a user who knows the transform name has no direct path to its definition. It is sharper for the ported migration estate where feeds are still monolithic (see #226): one file holds the connection + router + handler, so the handler name appears nowhere in the tree or the filename. - -**Proposed:** index handlers / routers / transforms (and the outbound connections a handler sends to) by name in the MEFOR view search, and jump to the `@handler`/`@router`/`inbound`/`outbound` definition on a match — reusing the CodeLens / `openSteps` entry point so a hit can open straight into the Steps view. A `lens parse` (or a light `findElements` scan) over the config dir already yields the handler/router names. - -**Source:** owner report 2026-07-11 while previewing the shipped IDE against the ported migration estate — searched a transform name in the MEFOR view, got “No matching results”. Related: #226 (split monolithic feeds, which would also surface handler names as files). - ---- - ## 232. Steps view for routers > 🚧 **Filed 2026-07-30; ADR gate discharged 2026-08-05.** Value **5/10** · Difficulty **5/10** · _fill-in_. ADR 0076 Amendment D widens the grammar with a `route` row kind (§3 row enum + §4 recognition grammar), and CLAUDE.md §12's carve-out now names Routers; build handed off (`docs/releases/HANDOFF-232-router-steps.md`), not yet built. @@ -2507,24 +2485,6 @@ def route_demo_oru(msg): **Source:** Windmill/Kestra evaluation (2026-07-30); owner asked for it to be filed the same day. -## 235. Generate Steps view parameter forms from Python type hints - -> ✅ **Closed 2026-08-05 -- engine-emitted param schema (`lens schema` CLI) + schema-driven IDE renderer; int-to-number, the retype-trap fix, and enum-to-dropdown (convert_case/pad_field/arith_field/date_diff_field, narrowed to Literal) are all live; code-set picker is N/A (no editable code-set literal to attach to); code/control rows stay read-only.** Value **4/10** · Difficulty **4/10** · _fill-in_. Widens what is *editable* without widening the recognition grammar; sequence deliberately against #237. - -**Cluster:** IDE & Authoring. **Priority:** P2. **Verdict:** build (evaluate as its own lane). **Severity:** low. - -**What:** today a recognized row exposes **enabled inputs only for literal params**; anything else renders visibly disabled (`stepsView.ts:11-13`). Windmill's pattern is to derive a **JSON Schema from the script's Python type hints** and render the step's parameter form from that schema. Applied here: `lens parse` (or a sibling `lens schema`) emits, per recognized action, a small parameter schema derived from the vocabulary helper's own **type hints** — which ADR 0076 §2 already requires to be "fully type-hinted, mypy-strict". - -**Why it is attractive:** it widens what is *editable* without widening the **recognition grammar** — the expensive, ADR-amendment-gated axis. The row set stays exactly as recognized today; only the input widgets get richer (enum → dropdown, `Literal["upper","lower","title"]` → radio, int → number field with validation, code-set name → the existing `codesetList` picker). - -**Build sketch:** engine side, derive the schema from `messagefoundry/actions.py` signatures (stdlib `inspect`/`typing`, no new runtime dep — ADR 0076 §6.5 forbids one in phases 1–2); IDE side, replace the hand-rolled per-op input rendering in `stepsModel.ts` (`ADD_MENU_CATALOG`, `TOOLBAR_INSERT_DEFAULTS`) with a schema-driven renderer. Keep `code`/`control` rows read-only. - -**Open question:** whether the schema is emitted by the engine (one source of truth beside the vocabulary, matching the ADR 0072 L5/L6 split the lens already follows) or hard-coded in the IDE. Engine-side is the consistent choice and is the recommendation to test first. - -**Related:** #222, ADR 0076 §2 (typed, mypy-strict vocabulary), [ADR 0106](adr/0106-steps-view-add-dropdown-vocabulary-expansion-adr-0076-phase-b.md) (the 27-item palette this would re-render), #237 (per-argument input modes — same form surface, land them together or in a deliberate order). - -**Source:** Windmill/Kestra evaluation (2026-07-30) — "borrow the idea, not the product"; owner approved testing it as a separate lane. - ## 236. Test-this-step and test-up-to-step with pinned upstream values > 🔢 **Filed 2026-07-30 — not started.** Value **5/10** · Difficulty **4/10** · _fill-in_. Largely a stop condition + state dump on ADR 0072's traced dry-run; lookup rows must mock by default, not as an afterthought. @@ -2562,22 +2522,6 @@ def route_demo_oru(msg): **Source:** Windmill/Kestra evaluation (2026-07-30); owner approved. -## 238. OpenFlow step-attribute completeness pass over the engine vocabulary - -> ✅ **CLOSED 2026-08-06 — findings note delivered.** Value **1/10** · Difficulty **1/10**. The gap-map lives at [docs/research/openflow-step-attributes.md](research/openflow-step-attributes.md); OpenFlow remains explicitly **not** a compatibility target — the note is a vocabulary map, not a gap-to-close list. - -**Cluster:** IDE & Authoring / Engine. **Priority:** P3. **Verdict:** build (a review, not a feature). **Severity:** none — this is a gap-analysis task whose output is findings. - -**What:** read Windmill's **OpenFlow** step-attribute vocabulary as a **completeness checklist** against MessageFoundry's own step/connector semantics, and record what is missing, what is deliberately absent, and what is already covered under a different name. The attributes to walk: `retry`, `timeout`, `stop_after_if`, `skip_if`, `continue_on_error`, `mock`, `cache_ttl`. - -**Explicitly NOT the goal — do not target OpenFlow compatibility.** OpenFlow is an open standard (Apache-2.0, so safe to read and cite) but its `info.version` tracks Windmill's own release tag, i.e. one vendor's weekly train. Emitting or consuming OpenFlow is a **separate** question and is not authorized by this item. Adopting a *declarative artifact* remains declined by ADR 0076 §7 and #26. - -**Expected output:** a short findings note (a research doc or an amendment to this item) listing, per attribute: covered / not covered / deliberately declined, with the MessageFoundry construct that covers it. Some will already be covered engine-side rather than in the Steps view (retry/timeout live in connector + delivery semantics, not in a handler row), and saying so precisely is most of the value. - -**Related:** #222, ADR 0076 §7 (declarative artifact declined), #26 (the visual/declarative-authoring line). - -**Source:** Windmill/Kestra evaluation (2026-07-30); owner approved the checklist framing explicitly ("don't target compatibility"). - ## 248. Steps view: reclassify comment-only rows as a non-opaque note row > 🔢 **Filed 2026-07-30 — not started. UNBLOCKED 2026-07-30:** Value **6/10** · Difficulty **4/10** · _quick win_. the ADR gate is cleared — **ADR 0076 Amendment A is ACCEPTED and in force** (owner-ratified 2026-07-30), so the grammar widening this item needs is authorized and the build may proceed. Treat Amendment A §A.4's invariants as **build gates, not caveats**, and note §A.6: this item does **not** fix comment re-attachment on move/delete, nor the parent-nesting of a comment at the end of an `if`/`for` body. @@ -2751,139 +2695,6 @@ Note the item is **not** "the scanner is broken" — it is that the token *sourc --- -## 325. Leak gate's home-path detector is case-blind on Windows paths - -> ✅ **Closed 2026-08-05 — shipped in #177 (commit `88703a3a`), an ancestor of `main`.** The fix and its regression tests landed folded into that batch, not on a branch of this item's name. The `_HOME_PATH` drive-letter arm case-folds inline (`scripts/security/scan_forbidden.py:114-121`) — scoped to that arm, so the POSIX `/users/` REST route stays unmatched (whole-pattern `re.I` would have measured 47 false positives) — and the sibling `_WORKTREE_SLUG` folds whole (`:96`); casing fixtures at `tests/test_scan_tokens_source.py:701,731` pass. Value **6/10** · Difficulty **2/10** · _quick win_. - -> **AMENDED 2026-08-05 — the What / Why / Proposed / Source block below is the historical filing record, not current state.** Read it as the finding that scoped this work, not as a live gap: the fix and its regression tests shipped in #177 (see the CLOSED banner above). The `_HOME_PATH` snippet quoted under **What** (a literal `Users`, compiled with no flags) is the PRE-fix pattern; the shipped detector folds the drive-letter arm inline at `scripts/security/scan_forbidden.py:114-121` and the `_WORKTREE_SLUG` sibling folds whole at `:96`. The four-spelling FIRES/MISSED table records the pre-fix behaviour, the **Proposed** steps are all built, and the line anchors together with the "Verified open at HEAD (`12efbffc`)" line reflect the state at filing, not today. - -> **Note on the examples below.** Every path here writes the account segment as the placeholder ``, because `_HOME_PATH`'s negative lookahead exempts a segment beginning `<` — a literal account name in this item would trip the very gate it describes. Read `` as "a real login name"; the FIRES/MISSED column describes what happens once one is substituted. This is [#322](BACKLOG.md) in miniature: a placeholder written into tracked prose is itself scanned. - -**Cluster:** Security / Supply chain. **Priority:** P2. **Verdict:** build. **Severity:** medium. - -**What:** `scripts/security/scan_forbidden.py:99-106` compiles the structural home-path detector with **no flags argument**: - -```python -_HOME_PATH = re.compile( - r"(?:[A-Za-z]:[\\/]Users|/home|/Users)[\\/]" - ... -``` - -The drive letter is class-matched (`[A-Za-z]`) but `Users` is a **literal**, so only the canonical casing fires. Measured at HEAD by executing the module's own compiled pattern: - -| probe | result | -|---|---| -| `C:\Users\\proj` | **FIRES** | -| `c:\users\\proj` | **MISSED** | -| `c:/users//proj` | **MISSED** | -| `C:\USERS\\proj` | **MISSED** | - -Windows filesystems are case-insensitive, so all four name the **same** directory and disclose the same OS account. The gate blocks one spelling of it and waves through three. - -This detector is the odd one out in its own module: `[names]` token patterns default to case-insensitive (`scan_forbidden.py:347`, `flags = 0 if case == "s" else re.I`) and the estate file detectors pass `re.IGNORECASE` explicitly (`:440`). The module also states its own tie-breaking rule at `:336-338` — *"under-detection is the dangerous direction … Fail toward more detection"* — which this line violates. - -No test covers it. `tests/test_scan_tokens_source.py:559-583` (`test_absolute_home_path_is_flagged_but_placeholders_are_not`) is the only home-path test, and both its positive fixtures — a `C:\Users\\Code\thing` form and a `/home//src` form, written there with real-looking account segments — are canonical case. Nothing asserts a casing variant in either direction. - -**Why:** `forbidden-content (customer/PHI leak guard)` is a **required merge context** (`.github/required-contexts.txt`), and `.github/workflows/security.yml:449-452` names *"absolute home paths"* among the things it scans the whole tracked tree for. A green run therefore reads to a reviewer as "no internal-environment disclosure present." For the lowercased spelling that reading is unearned. It is a **structural** detector, so it is the control that is supposed to work even in a fork with no token source at all. There is no compensating control: `scan_forbidden.py:10-12` is explicit that gitleaks finds *secrets*, not this class. - -**Bounded honestly — this is latent blindness, not a live leak.** Scanning every git-tracked file at HEAD, the current pattern finds **0** home-path hits and the proposed fix also finds **0**: there is no lowercased home path sitting in the tree right now. The disclosure it fails to catch is an **OS account name** — not a credential, not PHI, not customer data. Nobody needs privilege or an exploit to trip it; the failure mode is a developer pasting a stack trace or a shell transcript in non-canonical case and the gate not noticing. The blast radius is one developer login name reaching a public repo, which is exactly what this detector exists for and no more than that. - -**Proposed:** - -1. Case-fold **only the drive-letter arm**, inline, leaving the POSIX arms alone: - - ```python - r"(?:(?i:[A-Za-z]:[\\/]users)|/home|/Users)[\\/]" - ``` - -2. **Do not reach for whole-pattern `re.IGNORECASE`** — measured, it adds **47 false positives** across the tracked tree, every one of them the web console's `/ui/users/…` REST route (`messagefoundry_webconsole/routes/admin.py:38`, `:91`; `docs/SECURITY.md:575`). That would red the required context on the first run. `/users/` is an extremely common URL path segment; `/Users/` is not. The asymmetry in the current pattern is load-bearing, and the inline form preserves it: measured **0** new false positives. - -3. Keep the exemption list (`Public|Default|runner|me|svc|you|…`) **case-sensitive**. Case-folding it would widen the exemptions on POSIX, where an upper-cased and a lower-cased spelling of the same exempt word are genuinely different accounts — and widening an exemption is the under-detection direction. (Note a pre-existing, unchanged over-match: a Windows path whose account segment is a **lower-cased** spelling of one of those exempt words fires today, because the exemption compares case-sensitively and the lower-cased form misses the literal. That is the safe direction; out of scope here.) - -4. Add the regression case to `tests/test_scan_tokens_source.py:559`, alongside the existing canonical fixtures — a lowercased and an upper-cased Windows path must both produce a hit, and the POSIX `/users/…` non-match should be asserted deliberately so the next person does not "fix" it into the 47-false-positive form. - -5. **Same fix site, sibling defect:** `_WORKTREE_SLUG` at `scripts/security/scan_forbidden.py:92` is case-blind the same way (`[a-z0-9]+`); an upper-cased slug — `claude/` followed by `Some-Task-a1b2c3` — is MISSED. (Written split on purpose, for the reason in the note above: once the fix lands, the joined literal trips the very detector it documents, and unlike `_HOME_PATH` the slug pattern has no `<…>` exemption to write it into.) `scripts/worktree/new.ps1:43,86` passes `-Name` through verbatim with no lowercasing, so an upper-cased worktree name is reachable. Narrower than the home-path case (agent-created slugs are lowercase by convention), but it is a two-character edit in the same block — take it in the same change or say why not. - -**Related:** `scripts/security/scan_forbidden.py` (`_HOME_PATH` :99-106, `_WORKTREE_SLUG` :92, call site :758-759), `tests/test_scan_tokens_source.py:559-583`, `.github/workflows/security.yml:446-493`, `.github/required-contexts.txt`, `scripts/worktree/new.ps1`. Sibling **#321** — same gate, same "green gate that cannot see the class" root cause, but the **opposite mechanism**: #321 is an incomplete *token source* (data, fixed by the owner updating a private secret) and explicitly scopes itself away from scanner defects; this is a *structural detector* defect (code, fixed by a regex edit) that is live even with no token source. Also **#322**, and the anonymizer's structural-detector item from this same audit. Note #321's **Related:** line cites `tests/test_scan_forbidden.py` for regression tests, but the home-path test actually lives in `tests/test_scan_tokens_source.py` — worth correcting when someone next touches #321. - -**Source:** public-repo disclosure audit, 2026-08-01. Verified open at HEAD (`12efbffc`) by executing the compiled pattern and by diffing the current, proposed and naive-`re.I` variants across every git-tracked file. - ---- - ---- - -## 327. No test asserts the private-path `.gitignore` block still ignores anything - -> ✅ **CLOSED 2026-08-10 — Proposed 1-3 shipped in `dddbdc32`, and the guard was PROVED ABLE TO FAIL rather than merely observed green.** `tests/test_private_paths_stay_ignored.py` pins all six rules in a literal `_PRIVATE_PATHS` list, asserts `git check-ignore -q` on a synthetic probe child plus an empty `git ls-files` per prefix, and carries a `len(_PRIVATE_PATHS) == 6` cardinality assertion so deleting an entry cannot silently delete its coverage. 13 passed. Made to fail on purpose 2026-08-10 by removing `/docs/security/` from `.gitignore`: RED, naming the rule (*"'docs/security/probe-327.md' is NOT ignored"*), restored byte-clean. The CI half is wired — `ci.yml` `alwayscodepath='^(\.gitattributes|\.gitignore)$'`, driven live, classifies a `.gitignore`-only change as **code**, so the guard fires on exactly the PR shape it exists to catch. The prose residual is fixed in the same change as this closure. Filed 2026-08-01. Value **6/10** · Difficulty **2/10** · _quick win_. Six `.gitignore` rules are the sole control keeping maintainer-internal security material out of a public commit since the publish deny-list was retired, and the repo-wide search for `check-ignore` matches exactly one hand-run script (`scripts/dev/setup-leak-gate.ps1:58`) covering a different file, so the boundary is defended by review attention plus a hook that lives inside the now-ignored `/.claude/` tree and no fresh clone gets; a pinned-literal test with a synthetic probe child, plus dropping `^\.gitignore$` from the `noncode` allowlist at `.github/workflows/ci.yml:658` — without that edit the guard goes green on exactly the PR it exists to catch. - -**Cluster:** Security / Publishing boundary. **Priority:** P2. **Verdict:** build. **Severity:** medium. - -**What:** `.gitignore:128` opens the block that replaced the retired publish deny-list, and states its own stakes at `.gitignore:131-132`: - -``` -# repo, a gitignore rule is now the ONLY thing keeping them out of a commit -- and the cutover runbook -# runs `git add -A`. Same failure shape as the leak-scanner token file, different files. -``` - -The rules are `/.claude/`, `/TRANSCRIPTS.md`, `/docs/security/`, `/docs/reviews/`, `/docs/marketing/` (`.gitignore:142-146`) and `/docs/CI-TOPOLOGY.md` (`.gitignore:160` — a **sixth** rule in the same block, in the same posture). All six match at HEAD and no tracked file sits under any of them, both confirmed directly: - -``` -git check-ignore -v docs/security/x.md # -> .gitignore:144:/docs/security/ docs/security/x.md -git ls-files -- .claude docs/security docs/reviews docs/marketing TRANSCRIPTS.md docs/CI-TOPOLOGY.md -# -> (empty) -``` - -Nothing asserts either half stays true. A repo-wide search for `check-ignore` matches exactly two files: the untracked `.claude/settings.local.json`, and `scripts/dev/setup-leak-gate.ps1:58` — which checks **one** path (`scripts/security/scan-tokens.local.txt`), and only when an operator runs that setup script by hand. `scripts/security/scan_forbidden.py` enumerates tracked files (`_git_tracked()`, `scan_forbidden.py:679-683`) but every check downstream is content-based — tokens, IPs, home paths — so it has no opinion about a path. None of `.pre-commit-config.yaml`'s hooks (ledger-gate, ruff, forbidden-content, gitleaks, actionlint, bandit) is path-prefix based, and `ci.yml` / `security.yml` contain no reference to `docs/security`, `publish-denylist`, `private-path` or `check-ignore`. - -The two nearest-looking guards are neither: `tests/test_scaffold.py:51-52` asserts a gitignore substring for the **scaffolded config repo** `messagefoundry init` writes, not for this repo; `tests/test_release_pipeline.py:38`'s `PRIVATE_CANARY = "docs/security/THREAT-MODEL.md"` guards the **sdist/PyPI** channel (the hatchling `only-include` vs `release.yml` leak-gate cross-check), which is a different publication path from `git commit`. - -**Why:** this is the project's own evergreen lesson pointed at the highest-consequence boundary it has — the one deciding whether maintainer-internal security material (threat model, ASVS assessments, point-in-time review findings, per `docs/SECURITY-DOCS-POLICY.md`) is public. It is defended today by a text file nobody checks and by review attention. - -**Bounded honestly — the blast radius is what it is and no more:** - -- **Nothing is exposed right now.** All six rules match and zero files are tracked under them. This is a preventive gap, not a live leak. -- **It is not an attacker-exploitable defect.** Reaching it needs push access to this repo — reordering a rule, adding an un-ignore above one, resolving a merge conflict in the block, or `git add -f`. Anyone with that access could publish those documents deliberately in one commit. The guard defends against **accident and drift**, not against a hostile committer, and should be valued that way. -- **No PHI, no credentials.** The private set is prose about the system's posture. Real secrets are covered separately (`.env`, `*.key`, `*.pem` at `.gitignore` lines above, plus gitleaks in `.pre-commit-config.yaml`). -- **The obvious compensating control does not actually travel.** `scripts/hooks/block-blanket-git-stage.ps1` denies `git add -A` — the exact command `.gitignore:132` warns about — but it is wired through `.claude/settings.json`, which is itself inside the now-gitignored `/.claude/` tree and **untracked** (`git ls-files .claude/settings.json` is empty while the file exists on disk). It is a local Claude Code session control, fail-open by design, absent from a fresh clone or a new `git worktree add`. Do not count it as coverage. - -**Proposed:** - -1. Add `tests/test_private_paths_stay_ignored.py` with a **pinned literal list** of the six rules and two assertions per entry: (a) `git check-ignore -q` exits 0 for a synthetic probe child (`docs/security/__probe__.md`) — probing a synthetic path, not a real private file, so the test is valid in a public checkout where the private tree is absent by definition (the groundedness problem `tests/test_release_pipeline.py:117-127` already worked through); (b) `git ls-files` returns nothing under the prefix, since a gitignore rule never un-tracks a file that got added first. -2. **Pin the list in the test; do not parse it out of `.gitignore`.** Guarding a file by parsing that same file is how this repo already burned itself once — `tests/test_feature_map_claims.py:52-55` records a `.gitignore` marker-block parser whose marker existed only inside the test, exercised against a `tmp_path` fixture: *"It was a check that could not fail."* -3. **Wire it where it will fire on the PR that breaks it.** `ci.yml:472` puts `^\.gitignore$` in the docs-only `noncode` allowlist, so a `.gitignore`-only PR sets `code=false` and the `Tests (pytest)` step (`ci.yml:226-227`) is skipped — a pytest-only guard would go green on exactly the change it exists to catch, and would only fire on the post-merge push to `main`. Fix by dropping `^\.gitignore$` from that regex (a `.gitignore` edit is not a docs edit; it is the publishing boundary), and/or adding a `local` pre-commit hook alongside `ledger-gate` with `always_run: true` / `pass_filenames: false`. The CI arm is the load-bearing one — `.pre-commit-config.yaml`'s own header shows the hooks need a per-clone `pre-commit install`. -4. Consider promoting the resulting context per `.github/required-contexts.txt` rather than adding a paths-filtered workflow — a paths-filtered required check is the required-but-absent trap `manifest-lint.yml` documents. - -**Also (small, same block):** `docs/SESSION-DRIFT-CONTROLS.md:69-71` links to `[.claude/settings.json](../.claude/settings.json)`, which `/.claude/` now ignores and which is untracked — the link cannot resolve in the public repo, and the paragraph presents the blanket-`git add -A` guard as an active control while pointing at a file no public reader has. Fix in the same commit. The stale comment above the `.claude/settings.local.json` rule ("settings.json is shared/tracked") is contradicted by the later `/.claude/` rule at `:142` and should go with it. - -> **DONE 2026-08-10, with one residual named.** The dead link is removed (not repaired — it named a path -> no reader outside the maintainer's machine has) and the paragraph now states the guard's real reach: -> the script is tracked, its `PreToolUse` matcher is not, so a fresh clone and every `git worktree add` -> come up without it. -> -> **`scripts/docs/link_check.py` could never have caught this, which is the part worth keeping.** -> `.claude/` sits in that script's `WITHHELD` tuple, and the exemption `continue`s **before** `checked -> += 1`. Measured 2026-08-10 by planting two hrefs in a tracked document: a missing **non-withheld** -> path took the run to `FAIL: 1 unresolved` and the link total from 5359 to 5360; the same missing path -> under `.claude/` left the run `OK` **and the total unchanged at 5359** — the href was not merely -> resolved, it was never counted. A green link gate is not evidence about this class. (The same trap is -> already recorded from the other side in `tests/test_link_resolution.py`, whose first repo-wide -> measurement undercounted by 7 because `.claude/` was *present* in a long-lived local checkout.) -> -> **Residual, not fixed here:** the stale `.gitignore` comment. `.gitignore:84` still reads -> `# Claude Code: settings.json is shared/tracked; settings.local.json is machine-local (never commit)`, -> contradicted by `/.claude/` at `:142`. It is a comment with no mechanical effect, and `.gitignore` is -> outside this lane's file list, so it is carried to the owner rather than edited: replace "settings.json -> is shared/tracked" with a note that the whole `/.claude/` tree is ignored by the private-paths block -> below. - -**Related:** `.gitignore` (lines 128-146, 160), `scripts/security/scan_forbidden.py`, `scripts/dev/setup-leak-gate.ps1`, `.pre-commit-config.yaml`, `.github/workflows/ci.yml` (`noncode` at :472, pytest gate at :226), `tests/test_release_pipeline.py`, `tests/test_feature_map_claims.py`, `tests/test_scaffold.py`, `scripts/hooks/block-blanket-git-stage.ps1`, [`docs/SECURITY-DOCS-POLICY.md`](SECURITY-DOCS-POLICY.md), [`docs/SESSION-DRIFT-CONTROLS.md`](SESSION-DRIFT-CONTROLS.md), #321, #322. - -**Source:** public-repo disclosure audit, 2026-08-01. Verified against HEAD `12efbffc`; the audit flagged the finding as unconfirmed, and the absence of any such test/hook/gate is confirmed here. - ---- - ---- - ## 328. `audit-verify` cannot detect a truncated audit tail > 🚧 **Status OPEN — Proposed 1-2 SHIPPED 2026-08-04, Proposed 3 DEFERRED.** `messagefoundry audit-anchor` (`--service-config` / `--db` / `--json`, with the same SQLite missing-DB refusal as its verify twin, so a typo'd path cannot mint an empty database and print an anchor OF NOTHING) prints `COUNT:HEAD`, and `audit-verify --expected-anchor COUNT:HEAD` / `--expected-anchor-file PATH` feeds it into the already-present `expected_anchor=` keyword — no comparison-logic change and no store migration, as filed. `docs/FEATURE-MAP.md`'s hand-maintained CLI count moved 30 to 31 with it. **Proposed 3 — the `[integrity]` startup-anchor key — is NOT built, which is why this stays OPEN.** The reason is measured, and pinned by `test_an_anchor_goes_stale_on_the_next_appended_row`: the shipped comparator is an EXACT point-in-time seal (row count *and* head hash), so a stored anchor consumed by the startup auto-verify would fire a false `integrity_drift` on essentially every restart, because any running instance writes audit rows. It needs a seal-on-stop / check-on-start design (or a monotonic-prefix comparator) before it is worth wiring, and the plumbing is a THREE-file edit — `config/settings.py`, `pipeline/engine.py`, and `api/app.py`'s `create_managed_app`, which is the only route an `[integrity]` key reaches the Engine by, and which the multi-session plan had scope-dropped. `[integrity].audit_verify_on_start` therefore remains a bare walk and still cannot see a truncated tail; that limit is now stated on its own `docs/CONFIGURATION.md` row and in ADR 0014 §16.4.2. The SQL Server and Postgres `audit_anchor` CLI tests are written and collect cleanly but have **never executed locally** (no Docker daemon) — they are CI-verified only. _(was 5/10 · 3/10.)_ @@ -2931,105 +2742,6 @@ This is **wider than the disclosure describes.** [`CONFIGURATION.md:718`](CONFIG --- -## 329. Five `MEFOR_ALLOW_INSECURE_TLS` cells bypass the ADR 0092 clamp - -> ✅ **SHIPPED 2026-08-06 — the four out-of-gate insecure-TLS cells now route through the ADR-0092 clamp.** Value **6/10** · Difficulty **4/10** · _quick win_. LDAPS (`auth/ldap.py`), the SFTP host key (`transports/remotefile.py`), the webhook sink (`pipeline/alert_sinks.py`) and the AI-broker (`transports/ai_broker.py`) now gate the `MEFOR_ALLOW_INSECURE_TLS` escape through `weakened_tls_escape_permitted[_here]` — the instance posture threaded into `AuthService` / `create_app`'s out-of-gate constructors — so on an enforcing production-PHI instance the escape is inert and an unverified/cleartext hop stays refused. The fifth cell the heading names (Direct SMTP) was already clamped in #323, so this converted the remaining four. - -> ⚠️ **AMENDED 2026-08-03 — the census is FOUR, not five: #323 landed and took the Direct SMTP cell.** The heading, the evidence table (*"Confirmed at HEAD"*) and Proposed §1 all still name `transports/direct.py:170` as an unclamped cell, but that file now holds **no call to the raw predicate at all** — it imports only `weakened_tls_escape_permitted_here` (`messagefoundry/transports/direct.py:63`) and gates both arms on it (`:197` cleartext SMTP, `:215` `tls_verify=false`), with the #323 rationale — including its own warning that this absence is scoped to that file and never repo-wide — at `:182-196`; `:170` is now unrelated cert-loading. The Scope note called this in future tense and the 2026-08-03 banner already enumerates only four cells while still calling them *"five per-site facts"*, so read the table as **at least four** sites still reading the unclamped `insecure_tls_allowed()`: the SFTP host key (`messagefoundry/transports/remotefile.py:375`, feeding `AutoAddPolicy`/`RejectPolicy` at `:392-394`), LDAPS (`messagefoundry/auth/ldap.py:113`), the webhook alert sink (`messagefoundry/pipeline/alert_sinks.py:291` — the item cites `:290`) and the AI broker (`messagefoundry/transports/ai_broker.py:140`). -> -> ⚠️ **Do not read the banner's "the cheap in-gate half shipped with #323" as discharging Proposed §1.** #323 took `direct.py`; it did **not** take `remotefile.py:375`, the other in-gate cell §1 names, which still reads the raw predicate. §1 therefore shrinks to a single one-line swap rather than closing, and §2's out-of-gate work is entirely untouched — LDAPS, the webhook sink and the AI broker are all still raw, including the LDAPS cell this item ranks first. - - -**Cluster:** Security / TLS posture. **Priority:** P2. **Verdict:** build. **Severity:** medium. - -**What:** ADR 0092 decision 2 introduced `weakened_tls_escape_permitted(posture)` so the blunt global `MEFOR_ALLOW_INSECURE_TLS` can never relax a hop on an enforcing PHI instance (`config/settings.py:226-230` — *"if not insecure_tls_allowed(): return False … return not (posture.enforcing and posture.is_phi)"*). Five cells never adopted it and still call the raw predicate. Confirmed at HEAD: - -| Cell | Site | What the env var buys | -| --- | --- | --- | -| SFTP host key | `transports/remotefile.py:375` | `self._accept_unknown = insecure_tls_allowed()` → paramiko `AutoAddPolicy` instead of `RejectPolicy` (`:392-394`) | -| Direct (S/MIME) SMTP | `transports/direct.py:170` | `if not insecure_tls_allowed():` → cleartext SMTP submission | -| LDAPS | `auth/ldap.py:113` | `if not insecure_tls_allowed():` → `ad_tls_verify=false`, i.e. `ssl.CERT_NONE` on the bind (`:131`) | -| Webhook alert sink | `pipeline/alert_sinks.py:290` | `if scheme == "http" and not insecure_tls_allowed():` → cleartext alert POST | -| AI broker | `transports/ai_broker.py:140` | `if scheme == "http" and not insecure_tls_allowed():` → the `[ai].api_key` credential on cleartext http | - -The inconsistency is sharpest *within a single file*. `transports/remotefile.py` decides three escape questions: FTPS `tls_verify=false` at `:176` and credentialed plain-ftp at `:577` both go through `weakened_tls_escape_permitted_here()`; the unknown-host-key question three hundred lines away does not. Likewise `transports/email.py:134` gates cleartext SMTP on `weakened_tls_escape_permitted_here() or config.tls_hop_attested or config.cleartext_accepted`, while its near-identical Direct sibling at `direct.py:170` consults nothing but the env var. - -This is an omission, not a recorded decision. [ADR 0092](adr/0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md):14-19 lists six non-connection cells the escape "survives for" — engine→store TLS, LDAPS, the webhook alert sink, the AI broker, the `[logging]` forwarder, the API PHI-read serve hop — but *survives* is a statement about the variable, not about the clamp: three of those six are clamped in code (store via `store/sqlserver.py:1498`, the forwarder and the PHI-read hop via `hop_insecure_escape_downgrades`) and three are not. And `config/settings.py:203-207`, which enumerates the surviving clamped cells, names only the forwarder and the PHI-read hop — LDAPS, the webhook sink and the AI broker appear in no list at all. - -**Not** part of this: `transports/database.py:110-113` reads `insecure_tls_allowed()` only on the `posture is None` arm and `hop_insecure_escape_downgrades(...)` otherwise. That is the documented unstamped fallback (`config/settings.py:228-229`), same as the store's, and was examined and excluded. - -**Why:** the clamp exists to contain an *operator mistake*, and that is the whole of the blast radius here. `MEFOR_ALLOW_INSECURE_TLS` is not attacker-influenceable — setting it on a Windows service means editing the NSSM service definition or machine environment, which needs Administrator, and an Administrator can already do strictly worse (the config dir is executed as the service account; `config/settings.py:247-256`). Nobody reaches these cells over the network. So this is **not** a remotely exploitable vulnerability and should not be described as one. - -What it *is*: the realistic failure is a dev/CI environment variable riding into a production service definition — the exact scenario ADR 0092 decision 2 was written for, and the reason the store, MLLP, FTPS and plain-ftp cells were converted. With it set, an enforcing production-PHI instance silently accepts an unknown SSH host key (trust-on-first-use against a MITM on a PHI file feed), disables LDAPS certificate validation on the service-account and user binds, and puts the `[ai].api_key` on the wire. The LDAPS case is the one worth ranking first: it is instance-wide rather than per-connection, it is the authentication substrate for every AD identity, and `auth/ldap.py`'s own comment claims the refusal means it "can no longer be silently turned on in production" — which is true of the refusal but not of the clamp. - -The AI-broker cell has an additional argument: `transports/smart.py:126-149` moved the *same* question — a credential on a cleartext token endpoint — off the raw escape and onto `refuse_cleartext_credential_hop` in commit `a3015196`, with a comment describing exactly this defect (*"It used to read the raw, UNCLAMPED `MEFOR_ALLOW_INSECURE_TLS`"*). `ai_broker.py:140` is the un-migrated twin of a cell fixed days ago. - -**Additionally — converting all five is what makes the property *checkable*, not just true.** While these five remain, "no unclamped escape survives on an enforcing PHI posture" is five separate per-site facts, each verifiable only by opening the site and reading it, and each silently falsified by a sixth cell added later. Convert them all and it collapses into **one repo-wide invariant**: the raw `insecure_tls_allowed()` becomes unreachable outside `config/settings.py`'s own clamp, so the absence of the raw predicate is checkable everywhere at once, with `weakened_tls_escape_permitted_here` as the thing that must still be present. Today the property is a convention enforced by review; afterwards it is an invariant enforced by a grep — and a *new* unclamped cell fails immediately instead of waiting for the next audit to enumerate it. - -That distinction matters concretely for the ASVS record. The scorecard's absence-claim mechanism runs regexes over the whole `*.py` corpus and **cannot scope a grep to one file**, so a per-connector claim ("`direct.py`'s escape is clamped") is not expressible and has to be carried as stated-but-unchecked prose. A repo-wide claim is expressible and machine-verified on every commit. So this item is not only five leaks to plug: it is the difference between a security property that must be re-audited by hand and one that a gate can hold. *(Framing contributed by the ADR 0156 ASVS-sweep session, 2026-08-02.)* - -**Scope note, because the count is moving and two censuses will disagree.** #323 routes `transports/direct.py` and `transports/email.py` through the clamp, taking the remaining set to four once it lands — so a census taken on that branch disagrees with one taken on `main`, and neither is wrong. Measured at `main` by counting **`ast.Call` nodes**, not matching lines: six real call sites outside `config/settings.py` — `auth/ldap.py`, `pipeline/alert_sinks.py`, `transports/ai_broker.py`, `transports/database.py`, `transports/direct.py`, `transports/remotefile.py`. `transports/database.py` is the documented unstamped fallback, excluded above; `transports/mllp.py` matches a naive grep for the raw name but its occurrence is **prose inside a docstring, not a call at all**. A line-based census reports it as a further site; an AST-based one does not — which is the instrument distinction, not a detail about this item. - -**Proposed:** convert all five, but note that a blanket swap to `weakened_tls_escape_permitted_here()` would silently fix only two of them. - -1. **In-gate cells — a one-line swap each.** `remotefile.py:375` and `direct.py:170` are built inside `build_check_registry`/`wiring_runner`'s `active_hop_posture` scope (`config/tls_policy.py:587-603`; the stamping sites are all in `pipeline/wiring_runner.py`), so `weakened_tls_escape_permitted_here()` reads a real posture there — byte-identical to how `remotefile.py:176`/`:577` and `email.py:134` already behave. -2. **Out-of-gate cells — thread an explicit posture.** `auth/ldap.py`, `pipeline/alert_sinks.py` and `transports/ai_broker.py` are constructed from `create_app`/`AuthService` (`auth/service.py:275`, `api/app.py:5335`), which never stamp the contextvar; `current_hop_posture()` returns `None` there and `weakened_tls_escape_permitted(None)` returns `True` (`config/settings.py:228-229`), so `_here()` would be **inert** — the fix would ship green and change nothing for LDAPS, the highest-value cell. Pass a posture explicitly, as the store does at `store/sqlserver.py:1498`. `create_app` already derives one at `api/app.py:1174-1179` (`_phi_read_posture = hop_posture_from_ai(ai_settings, enforcement=…)`) — thread that into the three constructors rather than deriving a fourth. -3. **Prefer the credential authority for `ai_broker.py:140`** — `refuse_cleartext_credential_hop` (`transports/rest.py:478-515`), matching the SMART fix, since it fail-closes on an unstamped posture (`rest.py:292-300`) and gives the same error contract. -4. **Consider whether the SFTP host-key cell belongs on this env var at all.** It is an SSH TOFU decision, not TLS; a dedicated per-connection `known_hosts` requirement (or a `host_key_accepted` declaration in the ADR 0153 idiom) would express it better than a global TLS switch. Filing the swap does not settle that; call it out in the fix PR. -5. Update the `docs/DEPLOYMENT.md`:408-418 bullet list (three *(Not clamped)* / *(raw escape)* annotations become *(Clamped)*) and the `config/settings.py:200-208` surviving-cells docstring in the same commit, and add regression tests asserting each cell refuses under `enforcement=enforce` + PHI **with the escape set** — the assertion that does not exist today for any of the five. - -**Related:** [ADR 0092](adr/0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md) decision 2 (+ its ADR 0153 amendment banner), [ADR 0153](adr/0153-collapse-the-posture-gradient-no-data-label-may-allow-a-cleartext-hop.md) decision 5, [ADR 0148](adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md); `messagefoundry/config/settings.py` (`insecure_tls_allowed` / `weakened_tls_escape_permitted` / `_here`), `messagefoundry/config/tls_policy.py`, `messagefoundry/transports/remotefile.py`, `messagefoundry/transports/direct.py`, `messagefoundry/transports/email.py`, `messagefoundry/auth/ldap.py`, `messagefoundry/pipeline/alert_sinks.py`, `messagefoundry/transports/ai_broker.py`, `messagefoundry/transports/smart.py` (the shipped precedent, `a3015196`); [`docs/DEPLOYMENT.md`](DEPLOYMENT.md) §*The `MEFOR_ALLOW_INSECURE_TLS` escape hatch*, [`docs/SECURITY-LOOSENING.md`](SECURITY-LOOSENING.md); tests `tests/test_asvs_phase0.py`, `tests/test_remotefile_transport.py`, `tests/test_direct_transport.py`, `tests/test_email_destination.py`, `tests/test_hop_refusal_residuals.py`; #200 (closed — it built the clamp for the store/MLLP/FTPS/plain-ftp cells but never enumerated these five); the SMTP-unverified-TLS item from this same audit (`transports/direct.py` appears in both, at different lines and with a different fix). - -**Source:** public-repo disclosure audit, 2026-08-01. The audit classified the `docs/DEPLOYMENT.md` disclosure as honest and keep-as-is — the doc correctly names all five as unclamped; this item is the weakness the doc describes. - ---- - ---- - -## 331. Anonymizer's fail-closed leak-check has no structural PHI detectors - -> ✅ **SHIPPED 2026-08-06 — structural PHI-shape detectors + unmapped-field coverage report + token-floor signal built.** Value **6/10** · Difficulty **4/10** · _quick win_. `leak_check`/`leak_report` now run high-precision structural detectors (dashed SSN, punctuated NANP phone, CX `MR`/`MRN`-typed identifier) over **the fields no rule matched**, record every present-but-unmapped field in a coverage report (`LeakReport.unmapped_fields`, carried into the `LeakError` on a refusal and exposed via the `on_report` hook), and record `token_floor_failure()` in every report, folding it into the fail-closed decision under the `require_live_denylist` **opt-in** lever — default off, so a token-less CI/OSS/fork load still passes with the structural detectors as the live backstop; a deployment that must refuse on an unloaded denylist sets the lever. The whole structural block is mirrored byte-identical into `tee/anon/leak.py` with a new engine/tee `leak_report` parity test; each detector was falsified. ADR 0030 §5/§7/Consequences amended (the "deferred" phrasing was stale). The aggressive/broad-shape tier (bare-digit DOB/SSN, name-like runs) stays deferred by owner call — it mass-false-positives on HL7 bodies dense with dates/order-numbers. - -**Cluster:** Security & Compliance. **Priority:** P2. **Verdict:** build. **Severity:** medium. - -**What:** `anonymize_checked()` is the function that "earns the right" to write a de-identified dataset somewhere shareable, and its entire verification is one call ([`messagefoundry/anon/__init__.py:88-96`](../messagefoundry/anon/__init__.py)): - -```python -output = anonymize(raw, salt=salt, overlay=overlay, rules=rules) -hits = leak_check(output) -if hits: - raise LeakError(...) -``` - -`leak_check` ([`anon/leak.py:59-61`](../messagefoundry/anon/leak.py)) is `_scanner().scan_text(text, include_estate=True)` plus `message_has_site_code(text)`. `scan_text`'s full body ([`scripts/security/scan_forbidden.py:784-795`](../scripts/security/scan_forbidden.py)) is: the `FORBIDDEN` name patterns, one routable-`_IPV4` check, and the `ESTATE_TOKENS` substrings. There is no MRN-shape, SSN-shape, DOB-shape, phone-shape or name detector anywhere on this path. The module's other structural detectors, `_WORKTREE_SLUG` (:92) and `_HOME_PATH` (:99), are called **only** from `scan_file` (:756, :758) and are unreachable from `leak_check` — so on the anonymizer path the live structural detector set is routable-IPv4 alone. - -Three of the four live detectors are token-sourced and load **empty** without a token file — the scanner "degrades to STRUCTURAL-ONLY (routable-IPv4 only)" (:23-25), and `message_has_site_code` is "Always False when no site-code prefix is configured" ([`anon/surrogates.py:335-341`](../messagefoundry/anon/surrogates.py)). The fail-closed floor that exists for exactly this case, `token_floor_failure()` (:547), is consulted only inside `main()` (:881); the module-level `reload_tokens()` (:671) that the anonymizer's import path uses never checks it. So on a fork or a token-less checkout, `anonymize_checked` returns a green "leak-check passed" having verified that the HL7 body contains no routable IP address — and says nothing about it. - -**This was hit in practice.** De-identifying `samples/messages/hapi-hl7v2/batch_18_messages.txt` in `f3c6d348` required a hand-authored overlay for the fields the default map omits — GT1-8/16/17/18, IN1-4/5/6/7/11/18/44, OBR-35, and a non-standard DST segment (per that commit's own message). Those omissions are real at HEAD: `DEFAULT_RULES` ([`anon/rules.py:68-121`](../messagefoundry/anon/rules.py)) covers GT1-3/5/6/7/12, IN1-16/19/36/49 and OBR-16/32 and nothing else, and `git log -- messagefoundry/anon/rules.py` shows the file unchanged since the clean snapshot. Nothing flagged their absence — a human reading the corpus did. The overlay was never committed (`git show --stat f3c6d348` lists 7 files, no `anon.toml`), so the derived knowledge is gone and the next corpus starts from the same blind map. - -**Why:** the framework's promise is that a leak-check makes a dataset *proven* PHI-free before it may be committed or shared. What it actually proves is the absence of a **known string list**; a real MRN is not a denylisted string. The gap is honestly documented — [ADR 0030](adr/0030-anonymization-test-harness-tee.md):265-266 states it verbatim ("a field whose PHI the rule map **missed** sails through the fail-closed gate *clean*") and :268-270 / :339-343 defer structural detectors as a candidate improvement. This item is to build that deferral, not to report it. - -Bounded honestly: -- **This is not a runtime data-plane defect.** Nothing under `pipeline/`, `store/`, `api/` or `transports/` imports `anon` — the only production caller is `tee anonymize-captures` ([`tee/__main__.py:47,519`](../tee/__main__.py)), and the harness's `anonymizer=` hook is optional and unwired by default ([`harness/reconcile/capture.py:46,57`](../harness/reconcile/capture.py)). No attacker-reachable path exists; no inbound message triggers it. -- **Exploitation is not the failure mode.** Reaching this code means already holding real captures — i.e. someone legitimately handling PHI, who could mishandle it more directly. The risk is a *human* one: a green result reading as an assurance it does not carry, and a PHI-bearing corpus being committed on the strength of it. -- **The primary control genuinely is rule-map completeness**, and the ADR says so. This is a missing backstop, not a broken control. The residual is the ordinary case of a corpus using a field nobody thought to map — which is precisely what happened in `f3c6d348`. -- **Free-text is already handled**: OBX-5/NTE-3 default to a blunt full-redact (ADR 0030 §3), so the highest-risk residual is not this one. - -**Proposed:** -1. **Scope shape detection to what the anonymizer did not touch.** ADR 0030 (~:255) is right that a broad shape search over HL7 mass-false-positives — bodies are dense with 6-9 digit runs. But `anonymize` knows exactly which fields it rewrote, so run structural detectors **only over the fields no rule matched**. That makes SSN/NANP-phone/date/MRN shapes tractable without a false-positive storm. -2. **Add a cheaper coverage report first.** Have `anonymize_checked` surface every segment/field present in the input with no rule and no explicit keep-decision ("N unmapped fields: GT1-16, DST-4, …"). This alone would have caught the batch_18 case, needs no shape heuristics, and is a much smaller change than (1). -3. **Stop degrading silently.** Wire the existing `token_floor_failure()` (`scan_forbidden.py:547`) into the `leak_check` bridge so `anonymize_checked` refuses — or demands an explicit opt-out — when the token tables load empty, instead of returning clean. Have `LeakError`/the clean path name which detector tables were live. -4. **Land the batch_18 overlay** as a committed `anon.toml` fixture, or fold those fields into `DEFAULT_RULES`, so the hand-derived rule set is reusable rather than re-derived. -5. **Negative tests.** No test asserts the leak-check can see structural PHI, and none asserts behaviour on an empty token load — which is why the hole is invisible. Same lesson as #321: a green gate is evidence only once you have proved it can see that class. Mirror any change into `tee/anon/leak.py` (`test_anon_parity` pins the two). - -**Related:** [`messagefoundry/anon/leak.py`](../messagefoundry/anon/leak.py), [`messagefoundry/anon/__init__.py`](../messagefoundry/anon/__init__.py), [`messagefoundry/anon/rules.py`](../messagefoundry/anon/rules.py), [`tee/anon/leak.py`](../tee/anon/leak.py), [`scripts/security/scan_forbidden.py`](../scripts/security/scan_forbidden.py), [`tee/__main__.py`](../tee/__main__.py), `tests/test_anon_core.py`, `tests/test_anon_parity.py`, [ADR 0030](adr/0030-anonymization-test-harness-tee.md) §5 + Consequences (the deferral this item builds), #36 (shipped — its "Verifiability" bullet is the claim this narrows; closed, so not an amendment target), #321 (sibling: the publish-path token *source* is incomplete — a different mechanism; its Proposed §3 cross-references this gap, and its "#320-adjacent" phrasing there is a mis-reference, since #320 is the windows-2025 MLLP ingress item), and the case-blind `_HOME_PATH` item from this same audit (a third, disjoint leak-gate mechanism). - -**Source:** public-repo disclosure audit, 2026-08-01. - ---- - ---- - ## 332. Release signing toolchain is unhashed > 🔢 **Filed 2026-08-01 — not started.** Value **6/10** · Difficulty **5/10** · _quick win_. Arbitrary code from any of ~30 floating transitives at `.github/workflows/release.yml:255` runs with the OIDC identity that then signs the wheel, writes the SLSA attestation and publishes to PyPI — a backdoored artifact carrying a *valid* Sigstore bundle and valid provenance — and no Dependabot ecosystem parses an inline `pip install X==Y`, so the pin rots with no trigger and no owner (the two siblings at `:104` and `:207`, the latter a `~=` range, float identically); the ADR 0034 hashed-lock mechanism is proven and running for `ci-scanners`/`ci-quality`, but `sigstore` is absent from every lock (`grep -c sigstore uv.lock` → 0), adding a seventh is a six-place lockstep edit, the resolve contamination may force the same excluded-by-decision call semgrep got, and no PR leg ever executes this path. @@ -3138,113 +2850,6 @@ Honestly bounded: **this is build-time only.** No PHI path, no running-engine su --- -## 337. handler-security lint: `getattr` indirection and the undecorated helper - -> ✅ **Done 2026-08-05 (#337).** Value **3/10** · Difficulty **3/10**. Both recall gaps in `_check_handler_security` (`checks.py`) are closed and pinned. A constant `getattr(mod, "name")` indirection now resolves in `_dotted_call_name`, so `getattr(os, "system")(...)` is flagged for `ambient-authority` (the shared resolver also flags a `getattr(time, "time")()` wall-clock read for `impure-transform`); and `phi-to-log` now scans undecorated `_*` transform helpers keyed on the first positional parameter, while `impure-transform` stays decorated-scope so the shipped `_pdf_mdm_transforms.py` ingest-time timestamp fallback stays clean. New `tests/test_checks_handler_security.py` cases cover both, and the change was proven green against `samples/config` before landing. Still an advisory-by-default filter — an evasion reaches neither the DEK nor the audit chain in either sandbox posture; ADR 0144 amended, to be re-scored upward when ADR 0147 (OS-level default-deny) lands. - -**Cluster:** Security & Compliance. **Priority:** P3. **Verdict:** build (small). **Severity:** low. - -**What:** Two execution-verified coverage holes in `_check_handler_security` ([`checks.py`](../messagefoundry/checks.py), ADR 0144), both still open at HEAD. - -*(1) `ambient-authority` sees only a literal name chain.* `_ambient_authority_hit` (`checks.py:690-721`) matches a bare `ast.Name` against `_AMBIENT_BARE_NAMES` — `frozenset({"eval", "exec", "compile", "__import__"})` at `checks.py:464` — then falls through to `_dotted_call_name`, which by its own docstring returns `None` "when it is not a pure Name/Attribute chain (e.g. the receiver is itself a call or subscript)" (`checks.py:549-560`). `getattr(os, "system")("…")` is precisely that shape: the outer call's `func` is an `ast.Call`, and the inner call's `func` is `Name("getattr")`, which is in no deny-list. Measured, in **strict** mode: - -```python -# /IB_T_handler.py -@handler("H") -def h(msg): - getattr(os, "system")("whoami") # line 7 — NOT flagged - globals()["__builtins__"]["eval"]("1") # line 8 — NOT flagged - mod = __import__("subprocess") # line 9 — flagged -``` -``` -_check_handler_security(cfg, strict=True) -# -> ok=False, required=True, "1 handler-security finding(s) … IB_T_handler.py:9 [ambient-authority]" -``` - -The opt-in Semgrep leg does not recover it either: `messagefoundry/security/semgrep/handler-security.yml:148-151` lists `eval(...)` / `exec(...)` / `compile(...)` / `__import__(...)` and no `getattr` pattern. ADR 0144:189-192 states this outright ("`getattr(os, "system")` remains a false-negative") — the defect is that nothing executable pins it, and the cheap resolution was never taken. - -*(2) `phi-to-log` is decorated-scope only, which excludes the documented transforms helper.* The rule loop is gated by `if _message_fn_decorator(node) is None: continue` (`checks.py:921-934`), `_body_calls` refuses to descend into nested defs (`checks.py:762-778`), and `_message_fn_decorator` is `FunctionDef`-only so an `async def` handler is out too (`checks.py:536`). Measured, in **strict** mode: - -```python -# /_feed_transforms.py (undecorated — the documented Hybrid helper) -def xform(msg): - log.info("transforming %s", msg.raw) -``` -``` -_check_handler_security(cfg, strict=True) -> ok=True, skipped=True, "no handler-security findings" -``` - -ADR 0144:193-195 records the decorated-scope trade, but justifies it with an **`impure-transform`** false positive ("the trade that keeps the shipped `_pdf_mdm_transforms.py` timestamp fallback clean") and then applies it to `phi-to-log` as well. `samples/config/_demo_oru_transforms.py` and `_pdf_mdm_transforms.py` exist, [`docs/CONNECTIONS.md`](CONNECTIONS.md) §"Decomposing by role" tells authors to put field-level transform logic there, and #226 is an estate-wide sweep to do exactly that — so the one CLAUDE.md §9 rule the lint encodes systematically skips the file the convention steers PHI handling into. - -**The third gap the audit named is narrower than described.** The non-recursive `base.glob("*.py")` at `checks.py:893`/`:898` (ADR 0144:196) is **not** an unscanned execution path. `load_config` globs `directory.glob("*.py")` non-recursively too (`config/wiring.py:3969`, and `:4392` for `validate_config`), and `_SiblingHelperFinder.find_spec` returns `None` for any dotted name and serves only `_`-prefixed top-level helpers from the config dir (`wiring.py:3902`, `:3908-3912`). A `.py` in a config subdirectory is therefore neither executed by the loader nor importable by a sibling — and `_assert_safe_config_source` is non-recursive for the same reason (`wiring.py:4194`, `:4324`). The lint's file set already equals the executable set. A recursive walk here would make the lint report on files the safe-source ownership gate never vets — an asymmetry in the other direction. #226 already parks recursion as a *loader* question; it belongs there, not here. - -**Why:** Bounded, and bounded hard. The lint is advisory by default (`checks.py:953`, `ok=not strict, required=strict`), so a finding blocks nobody unless an adopter opts into `--strict-handler-security` on their own CI. It governs code the adopter's own administrator authors, inside a directory whose write access is already the trust boundary (`_assert_safe_config_source`, `wiring.py:4194`/`:4324`) — anyone who can drop a `.py` there already has arbitrary in-process execution under the engine account, so this is **not** a privilege boundary and evading it buys an attacker nothing they did not already have. - -> ⚠️ **Rationale amended 2026-08-01 (ADR 0087 sandbox session) — the severity is right, the reason was not.** "The author already has in-process execution" is true at the **default** `[sandbox].mode=off`, and **false** under `mode=subprocess`, where the entire premise is that the author is *not* trusted with it. A severity floor resting on a posture-specific claim reads as settled and misleads the next reader. The rationale that holds in **both** postures: the lint is advisory and pre-deployment; under `mode=off` the author already has in-process execution, and under `mode=subprocess` an evasion still only reaches **host** actions the sandbox does not confine — `DEFAULT_FORBIDDEN_MODULES` (`pipeline/sandbox.py:84-95`) blocks `socket`, `ssl`, `asyncio`, `multiprocessing`, the I/O-bearing `messagefoundry.*` subpackages and `cryptography`, but **not `os` or `subprocess`** (verified at HEAD). ADR 0087 confines the **address space** (the child cannot reach the parent's DEK, audit chain or sockets), not the **host**; OS-level default-deny is ADR 0147, *Proposed with no code*. So an evasion reaches neither the DEK nor the audit chain in either posture. **Re-score upward when ADR 0147 lands**, at which point the lint becomes load-bearing for exactly the class OS confinement is meant to close. ADR 0144:171-174 and the `_check_handler_security` docstring (`checks.py:878`) both say so: "a filter, not a fix." There is no PHI-exposure path and no runtime behaviour change of any kind. - -What it *is*: an adopter who turns on the strict gate gets a **green build** on a Handler containing `getattr(os, "system")`, and gets a green build on a transforms helper logging `msg.raw` at INFO. Gap (2) is the one that actually costs something, because the miss is not a malicious bypass — it is the ordinary fallible-author case ADR 0144 exists for, landing in the exact file the project's own layout guidance created. Gap (1) is mostly a claim-hygiene problem: the ADR asserts the false negative in prose and no test proves it, so nobody notices if a future change silently widens or narrows it. - -**Proposed:** -1. **Resolve `getattr` on a known-dangerous root** in `_ambient_authority_hit` (`checks.py:690`): when the call's `func` is `getattr(, )`, splice the constant into the chain and re-run the existing predicate; when the second arg is **non-constant** on a root already in `_AMBIENT_ROOTS`/`_AMBIENT_OS_PATHS`, flag it directly (it is unresolvable statically, and that is the honest answer). ~15 lines, no new dependency, reuses `_dotted_call_name`. -2. **Widen `phi-to-log` past the decorated scope** — scan module-level functions in `_*.py` helpers (and nested defs inside a decorated body) for the same rule, keying on a parameter whose name matches the caller's message symbol or on `.raw`/subscript access. `impure-transform` stays decorated-scope: the ADR's stated FP rationale is specific to it, so widening only `phi-to-log` costs nothing against that rationale. Recalibrate against `samples/config/_demo_oru_transforms.py` + `_pdf_mdm_transforms.py` before landing. -3. **Pin both with tests** in `tests/test_checks_handler_security.py` — a positive for the `getattr` form and a positive for the undecorated-helper PHI log; today only the *negative* undecorated cases are pinned (`:205`, `:287`, `:298`), so the gaps are asserted in prose and nowhere in code. -4. **Update ADR 0144's residual list** (`:189-196`) as part of the same change: strike the getattr and decorated-scope-`phi-to-log` bullets when fixed, and rewrite the "Non-recursive" bullet to state *why* it is correct (the loader is non-recursive too) rather than listing it as a gap. -5. Optional: add a `getattr` pattern to `security/semgrep/handler-security.yml` for the opt-in taint leg, and fix the `_body_calls` docstring (`checks.py:763-766`), which claims "each nested def is scanned on its own iteration" — true only for a nested def that itself carries `@handler`/`@router`. - -**Related:** [`messagefoundry/checks.py`](../messagefoundry/checks.py) (`_ambient_authority_hit`, `_check_handler_security`, `_body_calls`, `_message_fn_decorator`), [`messagefoundry/security/semgrep/handler-security.yml`](../messagefoundry/security/semgrep/handler-security.yml), [`tests/test_checks_handler_security.py`](../tests/test_checks_handler_security.py), [ADR 0144](adr/0144-security-lint-gate-over-admin-authored-router-handler-config.md) (this lint), [ADR 0087](adr/0087-sandbox-subprocess-isolation.md) + #197 (the runtime half — SHIPPED), [`docs/ADOPTER-CI.md`](ADOPTER-CI.md) (the operator control listing, line 178), [`docs/CONNECTIONS.md`](CONNECTIONS.md) §"Decomposing by role", #226 (the Hybrid-layout sweep, and the loader-recursion question). - -**Source:** public-repo disclosure audit, 2026-08-01. ADR 0144 is honest and stays — the defect is what needs fixing. - ---- - ---- - -## 338. TLS key-exchange groups are inherited, not pinned - -> ✅ **SHIPPED 2026-08-06 (#338) — key-exchange groups documented as inherited, plus a report-only surfacing.** Value **3/10** · Difficulty **2/10**. `harden_kex_groups` pins nothing until `SSLContext.set_groups` lands in **Python 3.15**, so every built context inherits OpenSSL's default group list — forward-secret but wider than the approved pin — which makes this documentation accuracy plus observability, changing no live TLS behaviour. The three restatements that still read as *pinned* are corrected to say *inherited*: `CONTAINER-EXPOSURE-EVALUATION.md` and `ASVS-L2-PHASE0-CHANGES.md`, plus #200's Closes line in `docs/archive/backlog/BACKLOG-CLOSED.md` (11.6.2 annotated PARTIAL, see PHI.md §4). Added an additive report-only `kex_groups` field on `SecurityPosture` beside `fips_attestation()`, rendered on the console status page behind engine seam v18. The two Python-3.15 tripwire tests are left in place as the trigger to set the pin. - -**Cluster:** Security & Compliance. **Priority:** P3. **Verdict:** build. **Severity:** low. - -**What:** [`config/tls_policy.py`](../messagefoundry/config/tls_policy.py):150-152 returns without pinning whenever the API is absent — - -```python -set_groups = getattr(ctx, "set_groups", None) -if set_groups is None: - return None -``` - -`SSLContext.set_groups` is a **Python 3.15** addition, so on this tree (3.14.6 / OpenSSL 3.5.7) `hasattr(ctx, "set_groups")` is `False` and `APPROVED_KEX_GROUPS` (`tls_policy.py`:89) reaches **zero** of its six call sites — [`api/tls.py`](../messagefoundry/api/tls.py):55, [`transports/mllp.py`](../messagefoundry/transports/mllp.py):543 and :582, [`transports/dicom.py`](../messagefoundry/transports/dicom.py):145 and :463, [`transports/remotefile.py`](../messagefoundry/transports/remotefile.py):213. Every built context inherits OpenSSL's default group list. Re-measured 2026-08-01 against the real `build_api_ssl_context`, at both `tls_min_version` 1.2 and 1.3 (identical results): - -``` -approved = {'X25519': True, 'secp384r1': True, 'prime256v1': True} -non_approved = {'ffdhe2048': True, 'ffdhe3072': True, 'secp521r1': True, - 'secp224r1': False, 'sect571r1': False} -``` - -**The code and the primary docs are already honest about this.** The 2026-07-29 correction sweep fixed the docstrings (`tls_policy.py`:11-15, :114-148), [`PHI.md`](PHI.md):638 (now scored `[PARTIAL — … the group pin is INERT until Python 3.15]`) and :648-655, [`ASVS-L2-PHASE0-CHANGES.md`](ASVS-L2-PHASE0-CHANGES.md):230-231, and struck §4(b) of [ADR 0092](adr/0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md):170-172 with an amendment at :215-247. Three restatements survived it: - -1. [`CONTAINER-EXPOSURE-EVALUATION.md`](CONTAINER-EXPOSURE-EVALUATION.md):50 — under a heading that reads *"What is actually built (verification, not re-derivation)"*, the `build_api_ssl_context` row's **Confirmed behavior** cell says `optional ciphers, hardened KEX groups + strict X.509`, unqualified. This is the strongest surviving instance: the column asserts verification. -2. `BACKLOG.md`:6416 — #200's `**Closes (ASVS 5.0 L3):** 4.2.1, 4.4.1, 11.6.2, …` still claims 11.6.2 closed, while `PHI.md`:638 scores the same cell PARTIAL. #200's banner at :6412 carries the correction, so the item contradicts itself two lines later. -3. `ASVS-L2-PHASE0-CHANGES.md`:253 — the PQC migration row says *"add it to the pinned group/cipher policy"*, presupposing a pin. - -Separately: the docstring argues *"the return value is the point"*, but all six call sites discard it, so the report exists only in tests. `SecurityPosture` already carries a report-only read-out sourced from this same module (`api/app.py`:1528, `fips_attestation()`), and carries nothing for KEX groups. - -**Why:** the residual is **wider than policy, not weak**, and this item is documentation accuracy plus observability — not a transport weakness. Every group that gets in is forward-secret; `ffdhe2048`/`ffdhe3072`/`secp521r1` are the whole delta, and the genuinely weak `secp224r1` (112-bit) and `sect571r1` (binary-field) are refused. The forward-secrecy property ASVS 11.6.2's first clause is about comes from the enforced TLS 1.2+ floor, and `harden_cipher_suites` (`tls_policy.py`:334-364) **raises** on any non-forward-secret suite at every one of the same six sites — so nothing here admits static RSA/DH. There is no exploit path: an attacker cannot downgrade to anything the floor does not already permit; the only reachable effect is a *client* choosing a still-forward-secret group outside the preferred three. It is immaterial on the default `127.0.0.1` bind, where no TLS is presented at all. The cost of leaving it is a reader of `CONTAINER-EXPOSURE-EVALUATION.md` §0 or of #200's Closes line concluding the pin is enforced and not looking again — which is exactly how the "3.13+" error survived three assessments. - -**Proposed:** -1. Correct `CONTAINER-EXPOSURE-EVALUATION.md`:50 to say the groups are **inherited** (attempted pin inert until Python 3.15) and point at `PHI.md` §4 rather than restating the measured set — per CLAUDE.md §11, state it once and link. -2. Reconcile the ledger: drop `11.6.2` from #200's Closes line at `BACKLOG.md`:6416, or annotate it to match `PHI.md`:638's PARTIAL score. Two ledger surfaces must not disagree on one ASVS cell. -3. Reword `ASVS-L2-PHASE0-CHANGES.md`:253 to "the approved group/cipher policy". -4. Consider an additive report-only `kex_groups: str | None` on `SecurityPosture` fed by the discarded `harden_kex_groups` return (same shape as `fips_mode`/`openssl_version`, `api/app.py`:1528) so the inertness is operator-visible, not test-only. Additive → a `_ui_seam` bump. -5. **Do not delete or relax the tripwires.** `tests/test_tls_policy.py`:117 asserts the `None` unconditionally and `tests/test_api_tls.py`:1278 measures the accepted-group set with an assertion at :1318 that a non-approved group *does* get in. Both go red the day an interpreter grows the API — that red **is** the "re-evaluate when 3.15 lands" trigger, so no dated review is needed. Their failure messages already name the docs to re-derive. -6. **Do not** substitute `set_ecdh_curve`. It takes exactly one OpenSSL curve short name, so pinning through it would refuse two of the three approved groups (`tls_policy.py`:139-148 records the trap, including that `secp256r1` is a valid group-list alias but not a valid curve name — the curve spelling is `prime256v1`). - -**Related:** [`config/tls_policy.py`](../messagefoundry/config/tls_policy.py) `harden_kex_groups` / `APPROVED_KEX_GROUPS` / `harden_cipher_suites`; the six call sites listed above; [ADR 0092](adr/0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md) 2026-07-29 amendment; [`PHI.md`](PHI.md) §4; [`ASVS-L2-PHASE0-CHANGES.md`](ASVS-L2-PHASE0-CHANGES.md) §*TLS key-exchange & cipher posture*; [`Secure_Development_Standards`](Secure_Development_Standards.md) §3 (this defect is its worked example); `tests/test_tls_policy.py`, `tests/test_api_tls.py`; #200 (closed — its Closes line is fix (2) above; amending a closed item's prose is fine, but it must not gain an OPEN banner). - -**Source:** public-repo disclosure audit, 2026-08-01. Re-verified and re-measured at HEAD on the same date. - ---- - ## 340. Enable a GitHub merge queue: strict + no queue makes every merge a race that fails silently > 🔢 **Filed 2026-08-01 — not started.** Value **6/10** · Difficulty **4/10** · _quick win_. Build state confirmed: zero of the 21 files under `.github/workflows/` carries a `merge_group:` trigger, so difficulty 4 and the step-2-is-a-precondition reasoning are right. Value 8 is not. The rubric's `8` is "an ASVS L3 Partial on defaults, or a production blind spot with no workaround" — this is neither. It is a repo-workflow blind spot, and a workaround demonstrably exists and is exercised: `gh pr update-branch` (#74 landed via three merges from main, #119 landed via re-sync), plus a detector the project already BUILT for exactly this condition and which the item itself cites — `scripts/ci/check_stalled_prs.py` + `.github/workflows/stalled-prs.yml`. So the readiness signal is not in fact unfalsifiable from outside: a scheduled job reports the stalled set. That makes it "real gap, awkward workaround" = 6, one rung above the rubric's `4` for DX (the item's own cluster is Developer Experience & CI), and 6 is generous for a cluster the ladder caps at 4. At value 6, difficulty 4: quadrant stays quick win, but tier is P2 (P1 needs value >= 8, or value >= 6 at difficulty <= 2 — and this one is 4). _(was 8/10 · 3/10.)_ @@ -3347,22 +2952,6 @@ What is NOT settled is the mechanism. Two independent passes reached different a --- -## 342. Sandbox worker kill does not reap a grandchild holding the response pipe - -> ✅ **BUILT 2026-08-06 (local commit on fix-342-sandbox-reap; owner opens the PR).** Value **5/10** · Difficulty **6/10** · _money pit_. `SandboxSession._kill` now reaps the whole worker process tree — a Windows `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` job object the worker is assigned to before its boot frame, and a POSIX new-session process group killed with `killpg` (`start_new_session=True`) — so a grandchild the Handler spawned can no longer inherit fd 1 (the response pipe) and outlive the kill as a leaked orphan writing onto a pipe the parent believes belongs to a fresh worker. Best-effort process hygiene, not the trust control (ADR 0087's codec + per-dispatch id + unsolicited-frame check keep a stray grandchild frame harmless): a job-assign failure degrades to a single-process kill, logged. The reap logic lives in `pipeline/sandbox.py`; the `_sandbox_codec.py` and `docs/CONFIGURATION.md` prose was synced to match. The ADR 0087 / ADR 0147 residual co-design (and the vault threat-model note) is left to the owner — reported, not done here. - -**Cluster:** Security & Compliance. **Priority:** P2. **Verdict:** build (small). **Severity:** medium, low (likelihood: requires Handler-authoring rights, i.e. the same admin threat model as #339). - -**Bounded by the #339 correlation fix, not closed by it:** a grandchild cannot make the parent accept a *forged answer* — the per-dispatch `secrets.token_hex(16)` id is unguessable and the unsolicited-frame check is fatal to the worker. So the residual is **availability and process hygiene**, not misdelivery: the orphan can force repeated kill+respawn cycles on its own feed (each dead-lettering the message in hand, fail-closed) and accumulate leaked processes. - -**Fix direction:** spawn into a job object on Windows (`CREATE_NEW_PROCESS_GROUP` + a kill-on-close job) and a process group on POSIX (`start_new_session=True`, then `killpg`), so the whole tree dies with the worker. Note the platform asymmetry is the same one ADR 0147 already documents for confinement, so the two should be designed together rather than twice. - -**Related:** #339, ADR 0087 (residual now stated there), ADR 0147 (OS-level confinement — the natural home for the job-object work), #343 (the sibling fd-2 issue). - -**Source:** adversarial review of the ADR 0087 sandbox codec, 2026-08-01. - ---- - ## 343. Sandbox child stderr is inherited unframed into the engine log stream > 🚧 **Status OPEN (filed 2026-08-01).** Value **4/10** · Difficulty **3/10** · _fill-in_. The worker is spawned with `stderr=None` ([pipeline/sandbox.py:266](../messagefoundry/pipeline/sandbox.py)), so the child's stderr is the **engine's own stderr**, unframed and unattributed. fd 1 is the IPC channel and is strictly framed; fd 2 has no such discipline. Admin-authored Handler code can therefore write arbitrary bytes straight into the engine's log stream — including forged log lines, ANSI control sequences, or content that breaks whatever consumes those logs (NSSM captures stdout/stderr to files; see [docs/SERVICE.md](SERVICE.md)). @@ -3381,26 +2970,6 @@ What is NOT settled is the mechanism. Two independent passes reached different a --- -## 346. The sandbox import boundary is enforced only at runtime, under an off-by-default flag - -> ✅ **SHIPPED 2026-08-06 — a static `ast` import-boundary guard now pins it.** Value **4/10** · Difficulty **3/10** · _fill-in_. [tests/test_sandbox_import_boundary.py](../tests/test_sandbox_import_boundary.py) walks the `ast` import nodes of `_sandbox_codec.py` and `_sandbox_worker.py` and asserts none resolves under a `DEFAULT_FORBIDDEN_MODULES` prefix (imported from the runtime constant, never copied), with a committed positive control that each static import form the walker handles is seen and a negative control that benign `messagefoundry.*` imports are not flagged. Both files are clean today; the guard would red on first deployment if a future edit reintroduced a forbidden import, instead of failing silently only under `[sandbox].mode=subprocess`. - -**Cluster:** Correctness / test coverage. **Priority:** P2. **Verdict:** build (small). **Severity:** medium (blast radius: a feature is DOA for everyone who opted in), medium (likelihood: the codec's constructor set is precisely the surface that grows as the payload model does). - -**Why it fails selectively — the reason this wants a test and not a comment.** The population that could report the breakage is the population *not* running the default. A future violation yields a green CI suite, a byte-identical `mode=off`, and a hard failure **only** on installs that turned the sandbox on for security reasons. The failure mode is inverted: the more security-conscious the deployment, the worse its experience, and the quieter the signal reaching the maintainer. - -**Measured, not assumed (2026-08-02):** `git grep -l "FORBIDDEN_MODULES" -- tests/` returns nothing — no test references the constant in any form. [`_sandbox_codec.py`](../messagefoundry/pipeline/_sandbox_codec.py) imports exactly the types the two ends construct (`CodeSet`/`UnmappedKind`/`UnmappedPolicy`, `ContentType`, `CapturedResponse`, `RunContext`, `Send`/`SetMeta`/`SetState`/`WiringError`, `Message`/`RawMessage`), today all under `config/` and `parsing/` — so the invariant **currently holds**. This item is about keeping it that way, not repairing it. - -**Fix direction.** A static test that walks the imports of `_sandbox_codec.py` and `_sandbox_worker.py` (stdlib `ast`, transitively across first-party modules) and asserts none resolves under a `DEFAULT_FORBIDDEN_MODULES` prefix. Anchor it on the **constant**, never a copied list — two copies of a rule drift, and the copy that drifts is the one nobody is testing. - -**Measurement discipline — the part that decides whether this is worth building.** The test must be demonstrated to **fail** against a deliberately introduced forbidden import *before* it is trusted. An import-walker that silently resolves nothing passes for exactly the same reason a correct one does, so a green run proves neither. Have it report what it walked, not merely that it walked. - -**Related:** #339 (surfaced it; relocated `CapturedResponse`), ADR 0087 (the boundary), ADR 0013 (the loopback re-ingress that was DOA), #342 / #343 (the other two findings the #339 review filed but did not fix). - -**Source:** adversarial review of the ADR 0087 sandbox codec, 2026-08-01; the `CapturedResponse` violation is measured, not hypothetical. - ---- - ## 353. Gate the risk-acceptance register against the scorecard: nothing compares its cell lists to the record > 🚧 **Status OPEN (filed 2026-08-02).** Value **6/10** · Difficulty **2/10** · _quick win_. The ASVS risk-acceptance register is **ungated prose**. No CI check has ever compared the cell ids in its signed sign-off blocks against the verdict of record, and a manual cross-check found the lists had drifted substantially with **zero** alarm. @@ -3995,156 +3564,6 @@ only**). Every code citation above was re-resolved against `origin/main` at `887 and the `truststore` refutation were **executed for this filing**, not inherited. The verdict of record and the cell's current score live in the vault scorecard and are not restated here. -## 1006. A mutation that matches is not a mutation that bites: the absence-claim gate proves syntax, never behaviour - -> ✅ **SHIPPED 2026-08-06 — a new opt-in mode can prove an absence claim BITES, which the pattern -> check structurally cannot.** Value **6/10** · Difficulty **3/10** · _quick win_. -> `scripts/asvs/scorecard.py` gains a `--prove-absences` mode: per claim it applies the `mutation` to -> a scratch copy of the tree and requires a named `observable` (a pytest node id) to go RED, failing -> closed on any exit code that is not an honest test failure (an already-red baseline, an -> uncollectable node, or a mutation that only breaks import is a PROVE-ERROR, never a proof). So a -> well-formed reintroduction that would change nothing if applied CAN be caught the moment its claim -> carries an `observable` — but the default `verify` path is byte-unchanged and no authored claim -> carries one yet, so nothing new is blocked by this alone today. Two optional `Absence` fields -> (`mutation_path`, `observable`) feed it, a coarse same-file static backstop screens claims that -> carry no observable, the scratch copy refuses secrets / the store / `docs/security` (defence for the -> eventual vault run), and fixture negative controls plus a CLI exit-code test prove the mode itself -> can go red. Public repo script + fixtures only; wiring the mode over the vault's ~81 existing -> absence claims (untouched) and backfilling their observables is the owner's follow-up -> (`scorecard.py:14-16`, ADR 0156 §7). - -**Cluster:** Security & Compliance. **Priority:** P2. **Verdict:** build. **Severity:** medium — -the defect is in the instrument, not the engine, and a green instrument that cannot go red is the -class [ADR -0158](adr/0158-silent-controls-green-signals-that-mean-nothing-and-shape-over-detection.md) exists -to name. - -**What.** `check_absences` ([`scripts/asvs/scorecard.py`](../scripts/asvs/scorecard.py):387, -called from `verify` at `:495`) admits an *absence claim* — a scorecard assertion that some thing -is **not** in the corpus — and rejects it three ways: - -| Mode | Line | The question it actually asks | -|---|--:|---| -| INERT | `:395` | does `a.pattern` match `a.mutation`? | -| BLIND | `:401` | does `a.positive_control` still match the Python corpus? | -| FALSE | `:408` | does `a.pattern` match the Python corpus? | - -`:395` is `re.search(a.pattern, a.mutation)`. `mutation` is a plain `str` field of the same TOML -row (`Absence`, `:107-109`); the corpus is never consulted for it and it is **never applied to -anything**. So a claim whose `mutation` is a syntactically perfect, honestly-authored -reintroduction that *would change nothing observable if written into the code* passes all three -tests, is recorded as a verified absence, and is counted in the "verified N absence claims" line -at `:625`. - -There is a fourth failure mode and the gate has no name for it: **the mutation is well-formed, the -pattern fires on it, the control speaks, the corpus is quiet — and applying the mutation changes -nothing.** - -**Why — the worked instance, and why it generalises.** The claim is **ASVS cell 13.3.4's absence -claim**, which lives in the vault-only `docs/security/asvs-scorecard.toml` (`docs/security/` is -gitignored in this public repo — `git ls-tree -r origin/main -- docs/security` returns nothing, so -a session reading this here cannot open it; it is in the **MessageFoundry vault repository**). Its -mutation inserted a `raise` inside `_maybe_escalate_dek` -(`messagefoundry/pipeline/secret_rotation.py:319`, under the guard at `:341`, called from -`reconcile_rotation_meta` at `:309`). - -That exception has **exactly one destination in the engine**: `reconcile_rotation_meta` is awaited -at `messagefoundry/pipeline/engine.py:1051`, inside a `try:` opened at `:1050` whose `except -Exception:` at `:1062` has a body of one `log.exception(...)` call (`:1065-1067`). Applying the -mutation verbatim yields a logged traceback, a normal engine start, and an absence-claim regex -that now matches. The instrument would have gone from green to green. - -**`reconcile_rotation_meta` is ALSO awaited directly by three tests** — -`tests/test_secret_rotation_watcher.py:107`, `:423`, `:435` — where the raise propagates uncaught. -That distinction is load-bearing for the proposal below: it is the difference between *"no -observable exists for this mutation"* and *"an observable exists and the gate never names it."* -Step 1's design turns on which is true, so establish it before writing the field. The engine -destination is singular; the test call sites are not. - -**The handler at `:1062` is not the defect and must not be "fixed" by this item.** Its purpose -is correct and is written down at `:1063-1064` — *"A reconcile failure must never take the engine -down … Logged, not raised."* The defect is that nothing in the instrument asks where a mutation's -effect lands. - -It generalises because nothing about the mechanism was special. A mutation that raises into a -swallow, writes a field nobody reads, sets a flag nobody branches on, or edits a docstring -satisfies `:395` exactly as well as a real one. The instance is closed; the property that let it -through is not, and that property covers every absence claim already authored and every one -authored next. - -**The instance's replacement is itself unproven.** That mutation has been re-sited outside the -handler on the record side — the re-siting the DEK calendar-expiry item filed in this batch treats -as its implementation sketch — but **the replacement has not been proved by execution either**, -which is the whole point of this item. - -**Nearest existing mechanism.** Two, and both are the seam this extends rather than a substitute -for it. - -- The loader **already refuses** an absence claim carrying no `mutation` at all (`:236-244`) — so - "a required field, enforced at load, with a message telling the author what to write" is a shape - this file already has and can be copied rather than invented. -- The `Absence` docstring (`:88-104`) already anticipates **one** vacuity mode and closes it in - prose: *"Do NOT derive `mutation` from `pattern`. A value generated from the thing it validates - satisfies the check by construction, which would make this the most authoritative-looking - vacuous gate in the file."* That is the right instinct aimed at a different mode — it guards a - mutation *dishonestly* constructed. This item is about one constructed honestly and still not a - control. - -**Proposed.** - -1. **A required `observable` per absence claim** — the named artifact that goes red when the - mutation is applied: a `tests/test_x.py::test_y` node id, or a documented startup/handshake - refusal. Refuse to load a claim without one, reusing the `:236-244` refusal shape and its - message style. -2. **Prove it by execution, at least once per claim.** A `--prove-absences` mode that, per claim, - applies the mutation to a scratch tree, runs the named observable, requires it to **fail**, and - reverts. Without this, step 1 adds a *name* for a control rather than a control — and #1000 - states the standing rule in one sentence: a green run is evidence only once the gate has been - shown it can go red on that class. -3. **A cheap static backstop for the mode actually found**, filed honestly as a heuristic: flag a - mutation whose landing site is lexically inside a `try:` whose handler is a bare `except - Exception:` with a log-only body. It would have caught this instance. It proves nothing in - general and must not be written up as if it does. -4. **Negative controls for each new mode**, beside the existing per-mode tests in - `tests/test_asvs_scorecard.py` (`:233` INERT-on-prose, `:260` INERT-decided-before-the-corpus, - `:195` BLIND, `:214` FALSE). The file already has the pattern; match it. - -**The trap this fix must not walk into.** An `observable` field that is recorded and never -executed is the same defect one level further out — a field validated for *shape* while the -property goes unmeasured, which is precisely what `:395` already does to `mutation`. If only one -of steps 1 and 2 can be built, build **2**: an executed proof with no schema field is worth more -than a schema field with no proof. - -**Step 2 is the item; steps 1, 3 and 4 are its trim.** A mutation-testing harness — scratch-tree -management, subprocess test invocation, red-assertion, rollback — is materially larger than the -other three combined. Split, step 2 alone prices at 4 and the rest at 2; the filed **3** is the -honest blend of the two, and an implementer who builds only step 1 has not built this item. - -**Scope note, and the cost deliberately excluded from the difficulty.** The public repo holds the -script and its fixture tests; the real posture data lives in the **vault repository** and this item -does not touch it (`scorecard.py:14-16`, ADR 0156 §7). Landing steps 1–2 **invalidates every -absence claim already authored** — **81 cells carry one** — until each is given an observable. -That re-authoring is the real schedule cost, is named here deliberately, and is **not** priced -into the difficulty number, which prices only the `ruff` + `mypy --strict` + `pytest` remainder in -the public repo. **Restate that exclusion in the PR body**, or a reader who sees difficulty 3 and -then discovers 81 claims need observables will believe the estimate lied. - -**Trigger:** none — it has fired. The instance was found by hand, by executing a mutation the gate -had already passed. - -**Related:** #1000 (prove each required merge context can fail — the same property one level down, -on CI gates rather than a compliance instrument); #353 (a compliance artifact nothing compares to -the record); #347, archived (an assertion that passes for a reason unrelated to the property it -claims to test); the DEK calendar-expiry item in this batch (whose design borrows the re-sited -mutation this item says is still unproven); ADR 0158 (the defect class); ADR 0156 (scorecard as -data — the ADR that introduced `Absence`). - -**Source:** an ASVS build-or-accept costing pass, 2026-08-03. The instance's own mutation has been -replaced on the record side; this item is the class it exposed, not the instance. -`check_absences`, the `Absence` dataclass and the loader refusal were read at `origin/main` -`88703a3a` for this filing, as were the swallow at `engine.py:1050-1067`, the mutation's landing -site at `secret_rotation.py:341`, and the three direct test call sites. - ## 1007. Sweep all 345 ASVS cells for present-tense impact language — the record asserts live exposures that do not exist > 🔢 **Filed 2026-08-04 — not started. Scored 2026-08-04 → P2.** Value **6/10** · Difficulty @@ -4459,125 +3878,6 @@ probe anywhere" claim is a **four-package** grep result, and the absence-claim p from `tests/test_docs_db_grants.py`, not from the vault scorecard, which this session did not open. -## 1009. SOAP `body_secret_value_` is redacted, registered and documented — and never fingerprinted - -> ✅ **Built 2026-08-05 — Scored 2026-08-04, P2.** Value **5/10** · Difficulty -> **2/10**. `connector_secret_env_values`, the ASVS 13.3.4 runtime rotation fingerprinter, now -> filters connector secrets through `_is_secret_setting` (`config/wiring.py:725`) instead of bare -> `_SECRET_SETTING_KEYS` membership, so the prefix-only `body_secret_value_` SOAP body-secret -> class is fingerprinted and a rotation of it is auto-detected the way every sibling class is. The -> missing reverse gate (`test_registered_connector_secrets_are_reachable_by_the_fingerprinter`) now -> asserts every registered connector secret is reachable by the fingerprinter, so the "can never -> disagree" invariant is enforced rather than assumed and a future hand-added registry entry cannot -> slip through (ADR 0015). - -**Cluster:** Security & Compliance. **Priority:** P2. **Verdict:** build. **Severity:** low — a -monitoring gap on an opt-in connector secret class, **not** a disclosure. - -**The defect.** `connector_secret_env_values` -([`messagefoundry/config/wiring.py`](../messagefoundry/config/wiring.py):702) collects the -`env()`-sourced credential values the wired graph references; `pipeline/secret_rotation. -reconcile_rotation_meta` then keyed-MACs each with the DEK-derived MAC so a changed value -auto-detects a rotation. Its filter, at `:725`: - -```python -if name in _NON_ROTATABLE_SECRET_SETTING_KEYS or name not in _SECRET_SETTING_KEYS: - continue -``` - -`body_secret_value_` — emitted at `:2305` when a `Soap(body_secrets={token: env(...)})` map is -desugared to flat top-level settings — is **not** a member of `_SECRET_SETTING_KEYS` -(`:614-662`; grepped, zero hits). It is secret only via the prefix branch of `_is_secret_setting` -(`:686`): `return name in _SECRET_SETTING_KEYS or name.startswith("body_secret_value_")`. The -redaction path calls that helper. The fingerprint path does not. So the class is masked on -`/metadata` and in `graph --json`, registered as a critical secret, documented with a rotation -cadence — and invisible to the rotation watcher. - -**On "nothing is exposed" — the enumerable version.** No disclosure follows from this, because -both redaction consumers call `_is_secret_setting`, not the frozenset: `config/wiring.py:742` -(`is_secret = _is_secret_setting(name)`, the settings serializer) and -`config/connection_schema.py:107` (`"secret": _is_secret_setting(name)`, which is what -`connection schema --json` emits and what the VS Code form at `ide/src/connectionForm.ts:51` -consumes downstream). Those are the two, enumerated by `git grep -n "_is_secret_setting"` — **not -a closed-set claim about "every serializer surface,"** which no instrument in this filing -establishes. Re-run the grep rather than trusting the enumeration. - -**The fix is one line**, using the helper the module's own docstring (`:673-686`) names as the -single source of truth for both settings serializers: - -```python -if name in _NON_ROTATABLE_SECRET_SETTING_KEYS or not _is_secret_setting(name): - continue -``` - -`_is_secret_setting` is defined at `:672`, above the call site, and `body_secret_value_` is not -in `_NON_ROTATABLE_SECRET_SETTING_KEYS` (`:697`), so the change is additive: it enrols the class -and moves nothing else. The factory already forbids an inline literal, a `default=` and a `cast=` -on each body secret, so every one is a bare `EnvRef` that the `isinstance` check at `:727` -accepts. - -**Why it survived: the gate that should have caught it asserts the invariant it violates.** -`tests/test_secret_rotation_inventory.py:101` registers the class **by hand** — `"body_secret_ -value": "SOAP body_secret_value_ injected secrets (ADR 0015)"` — and -`test_registry_secrets_appear_in_rotation_schedule` (`:157`) requires it to carry a -rotation-schedule row. So the secret is inventoried and documented as rotatable. But -`test_secret_setting_keys_are_registered` (`:182`) enumerates **`_SECRET_SETTING_KEYS`** (`:204`: -`rotatable = set(_SECRET_SETTING_KEYS) - _NON_ROTATABLE_SECRET_SETTING_KEYS`) to find things that -must be registered — and `body_secret_value` entered `CRITICAL_SECRETS` without ever passing -through that set, so the gate cannot see the direction that is actually broken. Its own comment, -at `:188-189`, states the invariant that does not hold: the set is *"the single source of truth … -ALSO read by the ASVS-13.3.4 runtime fingerprinter `connector_secret_env_values`, so the -registration gate and the runtime rotation set can never disagree."* They disagree for exactly -this class, and that sentence is the reason nobody looked. - -**So the fix is two changes, not one.** The predicate at `:725`, **and the reverse assertion** — -every `CRITICAL_SECRETS` entry naming a connector setting must be reachable by -`connector_secret_env_values` — plus a regression test that builds a `Soap(body_secrets=...)` -outbound and asserts its env key appears in the returned map. Without the reverse assertion the -next entry added by hand repeats this exactly, and the comment at `:188-189` stays false. - -**Do not assume the rest of the set is clean.** An earlier draft asserted *"every other -rotatable connector credential rides `:725` correctly today"*; no check in this filing establishes -that, and the reverse assertion above is precisely the instrument that would. Treat the sweep of -the frozenset as part of the work, not as a settled fact. - -**"Moves no verdict" is right about the score and wrong about the record.** ASVS 13.3.4 stays -`partial` either way. But that cell's residual names this gap as extant and its re-anchor trigger -names `body_secret_value_*` joining or leaving the fingerprint set — so **landing this obliges a -same-day re-verify of the residual**. **The residual and its trigger live in the vault-only -`docs/security/asvs-scorecard.toml`** — `docs/security/` is gitignored here and `git ls-tree -r -origin/main -- docs/security` returns nothing, so a session that greps this repo for 13.3.4 will -find nothing and wrongly conclude the obligation is stale. The engine PR and the vault edit must -land **as a pair**, as the 13.2.2 (`1e9cc4c1` / `f2c017ce`) and 12.1.5 (`62fd628d` / `a8a5a1c2`) -pairings did. Say so in the PR body, or the code and the record drift apart in the very commit -that closes the gap. - -**Nearest existing mechanism:** none to build against — this *is* the mechanism, already shipped -and one predicate short. - -**Citation trap, flagged so it is not propagated.** `wiring.py:681-682` sources the prefix -branch to *"ADR 0015 amendment / BACKLOG #236"*. **That `#236` is an internal-ledger number and -does not resolve here** — public `docs/BACKLOG.md` #236 (`:2469`) is *"Test-this-step and -test-up-to-step with pinned upstream values"*, unrelated work. The two number spaces diverged -around #231 and overlap below 1000 by design; the overlap was deliberately left unrepaired -(`8e6e7fa3`: renumbering *"would only make stale citations resolve uniquely and WRONGLY"*). Cite -**ADR 0015** for this class, not a bare `#236`. - -**Trigger:** none — it is a defect, not demand-gated. - -**Related:** the absence-claim gate that proves syntax rather than behaviour (filed in the same -batch — the other instrument problem on the same ASVS cell); [ADR -0015](adr/0015-ws-soap-outbound-mtls-wssecurity.md) and its amendment, whose desugar -(`_hoist_body_secrets`) lives at `wiring.py:2249-2306` and is called at `:2414`; ADR 0158 (green -signals that mean nothing — the reverse-assertion half of this is an instance). - -**Source:** noticed during the ASVS build-or-accept costing pass, 2026-08-03, unrelated to any -cell that pass decided, and filed rather than folded into one. Re-verified against `origin/main` -`88703a3a` for this filing: the filter at `:725`, the frozenset at `:614-662`, the prefix branch -at `:686`, the exclusion set at `:697`, the desugar at `:2305`, the two `_is_secret_setting` -consumers at `:742` and `connection_schema.py:107`, and the registration plus gate comment at -`tests/test_secret_rotation_inventory.py:101` / `:182-209` were each read directly. - ## 1010. No licence-header gate exists in any language, and 196 first-party sources carry no SPDX tag > 🔢 **Scored 2026-08-04 → P2.** Value **7/10** · Difficulty **3/10** · _quick win_. AGPL-3.0-or-later is asserted twice — in `LICENSE` and at `pyproject.toml:29` — and then per-file provenance is left to habit. 981 of 1,044 tracked `.py` carry `SPDX-License-Identifier`, which makes the convention real and near-universal; the 63 that do not include **all 17 files of `messagefoundry/tray/`**, a package `only-include` puts in the wheel and `[project.gui-scripts]` gives its own entry point. Widen past Python and it is **196 of 1,181 tracked sources across six languages**. Five more files declare **Apache-2.0** in an AGPL project. Nothing — no hook, no workflow, no test — checks a licence header in any language. @@ -4709,131 +4009,31 @@ Retiring the tree costs the engine nothing operationally: **`tests/test_ech_egre - Does the keyword remove the need for the listener restart on a **greenfield** listener (still at `RegisterAllProvidersIP = 1`)? - What does a **brownfield** site do — one already at `RegisterAllProvidersIP = 0`? The keyword only helps when every subnet IP is published, so the expectation is that undoing the workaround still costs a restart. Confirm or refute; it decides whether the rewrite can promise existing deployments anything. -- What is the **engine-version floor**? The setting shipped 2026-07-10; `AOAG-DEPLOYMENT.md` carries no version vocabulary today. - -**Explicitly not this item:** the `db_lookup` gap. `transports/database.py` builds its own connection string and emits no `MultiSubnetFailover`, so a deployment reaching the same listener through the DATABASE connector needs the DNS-side configuration regardless of what this rig measures. Whether that connector should get the keyword is a separate owner decision — do not let a green result here be read as "the workaround is obsolete". - -**Do not extrapolate from the AD lab.** `plan-11/w19-ad-lab-integration-validation.md` covers AD/Kerberos integration and records this as *"needs the SQL AG rig"*. Its note that "all shipped — this is confirmation, nothing is blocked" is true of the code and false of the documentation. - -**Related:** #100 (the shipped setting), [`AOAG-DEPLOYMENT.md`](AOAG-DEPLOYMENT.md) §4.5 / §5.3, `messagefoundry/config/settings.py` (`multi_subnet_failover`, default `false`), `messagefoundry/store/sqlserver.py` (`connection_string`), [`CONFIGURATION.md`](CONFIGURATION.md). - -**Source:** filed 2026-07-31 while correcting two stale claims in `AOAG-DEPLOYMENT.md` (PR #99). The validation gap was visible only as a line in a plan-11 doc and was not tracked work — a closed item with outstanding validation, which is the shape that goes missing. - -## 1013. The `[auth] enabled=false` startup arm keys on the bind alone, so auth-off behind a declared terminator still starts - -> ✅ **Fixed 2026-08-06.** Value **7/10** · Difficulty **4/10** · _quick win_. The auth-off startup arm read `not settings.auth.enabled and not settings.api.is_loopback` (the bind alone), so it did not fire for a declared TLS-terminating proxy: a PHI instance with authentication **entirely off** behind a declared terminator would have started with **no refusal and no warning** on first deployment — while the same topology with auth ON but MFA off is refused by the gate #326 fixed. The two arms disagreed about what "exposed" means, in the same file, for the same topology. The auth-off arm now consults the single `instance_exposed` definition (hoisted above it), so it refuses on a non-loopback bind OR a declared terminator. - -**Cluster:** Security / startup gates. **Priority:** P1. **Verdict:** build. **Severity:** high on first deployment — no authentication at all on an off-loopback PHI instance. - -**Anchors, re-derived on `origin/main` at 17374679 now that #326 has merged.** These resolve today; verify them before starting. - -- `messagefoundry/__main__.py:1112` — `if not settings.auth.enabled and not settings.api.is_loopback:` — the auth-off arm. -- `messagefoundry/__main__.py:1917` — `instance_exposed = not settings.api.is_loopback or settings.api.tls_terminated_upstream` — the definition that already encodes the declared-terminator case, and now the ONLY one. -- `messagefoundry/__main__.py:1939` — `admin_exposed = instance_exposed` — #326's post-fix form, re-keyed onto the definition above. - -**The separation is the reason this is a separate item and not a one-line follow-on to #326.** `instance_exposed` is defined **805 lines BELOW** the auth-off arm, so the arm cannot reference it without hoisting the definition. #326 could re-key `admin_exposed` because the definition already sat above it; this cannot. - -**Why it is arguably worse than #326.** #326 was single-factor admin over the network. This is **no factor at all**. A deployment that follows the documented off-loopback topology, with a declared terminator and `[auth] enabled=false`, starts silently. - -⚠️ **THE REMEDY IS UNPROVEN — do not read this item as prescribing one.** Nobody has established that hoisting `instance_exposed` to the auth-off arm is safe. That arm runs **early** in the startup ladder, and whether the settings it reads are fully resolved at that point is unknown. **That ordering question is the actual work of this item**, not the two-line re-key it superficially resembles. - -> **AMENDED 2026-08-06 — remedy proven; the load-order question is resolved.** The prerequisite this item flagged as unproven holds. `instance_exposed`'s inputs are fully resolved where the auth-off arm runs: its two fields — `settings.api.host` (through `is_loopback`) and `settings.api.tls_terminated_upstream` — are read straight off the loaded config, and the only in-place mutation of `settings.api.*` between the arm and the former definition site is `serve_ui` (twice), which the predicate does not read. So the single definition was hoisted above the auth-off arm with a byte-identical value, and the arm was widened to consult it (refuse on a non-loopback bind OR a declared terminator). Exactly one definition site remains, per the pointer comment #326 left ("`instance_exposed` is NOT re-derived here") — the hoist shifts that comment's line, so it is named rather than pinned to a number. - -**#326 HAS LANDED** (PR #189), and the re-verification this paragraph asked for was performed at `17374679`: the arm moved `:1080` to `:1112`, `instance_exposed` moved `:2368` to `:1917`, `admin_exposed` is now `admin_exposed = instance_exposed` at `:1939`, and the separation narrowed from 1,288 lines to **805**. The duplicate definition at the former `:2368` is **gone**, replaced by a pointer comment at `:2454` ("`instance_exposed` is NOT re-derived here. It is defined ONCE, above"), so there is now exactly ONE definition site to move rather than two to keep in sync. **The load-bearing property survives the move and so does the difficulty-4 pricing:** the arm at `:1112` still sits ABOVE the definition at `:1917`, so it still cannot reference it without hoisting, and the ordering question is still the actual work. Only the numbers changed. - -⚠️ **A consequence of #326 that this item does not cover, and that no gate can see.** Re-keying `admin_exposed` onto `instance_exposed` means the MFA-at-exposure refusal now fires on a declared-TLS-terminator topology where it previously could not — a posture change under **ASVS 6.3.3**, whose citations all still resolve, so nothing went red. Raised by the vault drift-repair pass of 2026-08-04; 6.3.3 needs re-validating against the code rather than being assumed still correct. Not folded in here. - -**Related:** #326 (the sibling arm, same file, same gate family), #328. The ADR 0140 amendment on `plan-cli-exposure` records this residual but names no number, having been written before one existed — worth a follow-up edit now that this item is filed. - -**Source:** found by the #326 lane's own recon and handed over because filing needs `alloc.ps1` plus a ranked-table row, both outside a lane's permitted surface. The measurements are the lane's; the main-side anchors were re-derived at filing because the lane's numbers describe its post-fix tree and would not have resolved here. - -## 1012. ASVS gate summary line silently drops a verdict state: components sum to 344 against its own stated 345 - -> 🔢 **Filed 2026-08-04 — not started.** Value **5/10** · Difficulty **2/10** · _fill-in_. The gate's summary line prints five verdict states whose components sum to **344**, while the same line states a total of **345**. It omits `needs-review`. So the line cannot be reconciled against itself, and a reader who trusts it under-counts one state entirely. - -**Cluster:** Security / ASVS tooling. **Priority:** P3. **Verdict:** build (small). - -**Severity is low and worth saying why**, so nobody inflates it: no verdict is mis-scored, and the vault scorecard remains the record of record. The cost is that the summary is the line people quote — it was quoted as "the distribution" across a full session, and the omission propagated every time it was repeated. - -**Why it matters more than a cosmetic count.** A count that does not reconcile with its own stated total is the tell for a missing category, and the check that would have caught it does not exist. This is the same shape as several defects found the same day: an instrument that answers a narrower question than the one asked, and reports success. - -**Proposed fix.** Emit the missing state, and add an assertion that the printed components **equal** the printed total. The assertion is the durable half; the missing state alone would leave the next added verdict able to vanish the same way. - -**Related:** the ASVS scorecard tooling under `docs/security/` in the vault (not tracked here — see [`SECURITY-DOCS-POLICY.md`](SECURITY-DOCS-POLICY.md)). Recorded in vault `d0c5736a` §2. - -**Source:** handed over by the ASVS-cleanup session on 2026-08-04, which found it after quoting the line all day, and which could not file it because the ledger was held elsewhere. - -## 1015. OIDC relying party keys federated accounts on a reassignable username claim while the non-reassignable `sub` is discarded (ASVS 10.5.2) - -> ✅ **Closed 2026-08-06 — Option A shipped (subject-continuity guard); ADR 0142 Amendment A owner-ratified.** Value **7/10** · Difficulty **4/10** · _quick win_. The relying party keyed federated identity on a reassignable username claim while the non-reassignable `sub` was verified then dropped, so on first deployment a new holder of a retired username would have been handed the prior holder's account (ASVS 10.5.2). Fixed by pinning the federated identity to `(issuer, sub)` — two nullable store columns with idempotent three-backend migrations — and refusing a login whose username resolves to an account bound to a different `sub` (`federated_subject_conflict`); the account is still resolved by AD username and roles still come from LDAP. Residual: a legitimately reassigned username is refused with no rebind path, so an operator rebind action is the recommended follow-on. - -**Cluster:** Security / authentication. **Priority:** P1. **Verdict:** build. **Severity:** high on first deployment — account takeover without any credential compromise. - -**What is wrong.** `sub` is the only claim OIDC guarantees is stable and non-reassignable within an issuer. The RP verifies it and then discards it as identity, keying the local account on a display-oriented claim instead. Directory products reassign `preferred_username` routinely — a departed employee's name freed and reissued is ordinary lifecycle, not an attack. - -**Why value 7 and not higher.** It matches **#1013** (7/4): both are authentication-gate defects that admit the wrong principal. This one is more conditional — it needs an IdP-side reassignment — but it lands on an **existing** account rather than an empty one, which is why it does not sit below #1013. - -**Difficulty 4, and there is no migration cost.** Key on `(issuer, sub)` and keep the username as a mutable display attribute. Normally that is a data migration; here there are **zero deployments** (see CLAUDE.md §0), so there is no installed base to migrate. What remains is the model change, the AD/local-account interaction, and deciding what happens when an existing local username collides with a federated display name. - -**Related:** #1016 (same module, different failure class), ASVS 10.5.2. The V10 chapter report in the vault carries the full 14-item re-triage. - -**Source:** found during the ASVS V10 re-verification, 2026-08-04, and handed over because filing needs `alloc.ps1` plus a ranked-table row, neither of which is inside a build session's permitted surface. Confirmed as reported. - -## 1016. claims.py 500s on two malformed-IdP shapes with no closed-set audit row - -> ✅ **Fixed 2026-08-06.** Value 5/10 · Difficulty 2/10. Both malformed-IdP shapes — a non-ASCII nonce and a list `aud` carrying an unhashable element — now reject as named, audited ClaimsErrors (nonce_mismatch / claim_aud); on first deployment either would otherwise have surfaced as a 500 with no closed-set audit row. - -**Cluster:** Security / authentication robustness. **Priority:** P2. **Verdict:** build (small). **Severity:** low — availability and audit completeness, not an auth bypass. Neither path admits a bad principal; both turn a rejectable token into an unclassified 500. - -⚠️ **The two mechanisms below are NOT the ones originally reported, and the difference decides the fix.** Both were re-derived against the code at 32d0cef9 and tested directly. Filing the reported versions would have sent a fixer at checks that already exist. - -**1. `hmac.compare_digest` raises on a NON-ASCII str nonce.** Not "on two str" — two ASCII strings compare fine and return a bool. Measured: `compare_digest('abc','abc')` returns `True`; a non-ASCII operand raises `TypeError: comparing strings with non-ASCII characters is not supported`. And the guard reads `if not isinstance(token_nonce, str) or not hmac.compare_digest(...)`, so the `or` short-circuit means a non-str nonce can never reach the call — **type confusion is already closed, and non-ASCII is the ONLY remaining path.** The fix therefore belongs at the encoding boundary, not in an `isinstance` check that is already present. - -**2. `set(aud)` raises on a list containing UNHASHABLE elements.** Not "on a non-iterable". The line reads `audiences = {aud} if isinstance(aud, str) else set(aud) if isinstance(aud, list) else set()`, and measured, every non-list shape falls through cleanly — a bare int, `None` and a dict all yield an empty set with no error. The residual is a list whose elements are unhashable: a list containing a dict raises `TypeError: cannot use 'dict' as a set element`. - -**Why it matters more than a 500.** Both paths bypass the closed-set audit row that every other claim rejection emits, so a malformed or hostile IdP response becomes an unclassified error rather than a named, audited refusal — which is the record an operator would need to tell a broken IdP from an attacked one. - -**Related:** #1015 (same module, an identity-keying defect rather than a robustness one). - -**Source:** found during the ASVS V10 re-verification, 2026-08-04. The conclusions were reported correctly; both mechanisms were misstated and are corrected here, with the correction verified independently by the reporting session. - -## 1014. connscale smoke test's fixed 24-port block is not parallel-safe across worktrees; the flaky marker hides the collision - -> ✅ **SHIPPED 2026-08-06 — dynamic contiguous inbound-port allocation replaces the fixed 24-port block; the flaky marker is dropped.** Value **5/10** · Difficulty **3/10** · _fill-in_. `test_connscale_smoke_end_to_end` now reserves a random contiguous inbound-port block at runtime (`_free_contiguous_ports`), asserts contiguity at acquisition, and fails loudly if no free block is found, so a genuine cross-worktree collision surfaces as a red rather than a masked retry. - -**Cluster:** Testing / CI reliability. **Priority:** P3. **Verdict:** build (small). **Severity:** low — it costs retries and misdiagnosis, not correctness. - -**Why this is not a flake.** It was traced rather than assumed. There is no global `--reruns` in `addopts`, so only an explicitly-marked test can retry at all, and exactly two are marked; one skips locally. That leaves this test, carrying `@pytest.mark.flaky(reruns=2, reruns_delay=3)` with the comment *"CI runners are noisy: re-run clears"*. Three suites were run in parallel across three worktrees; two needed their retry and the third did not, because it won the race for the fixed block. - -**Why the label is the defect.** The retry is doing work the port allocation should be doing. Labelled *noisy runner*, a real contention bug becomes invisible — and this repo's own guidance is that a failure must be **proven** timing-dependent before being called a flake, precisely because the two previously-famous flakes here turned out to be a livelock and a test that was right. - -**The topology makes it routine, not exotic.** This project runs many checkouts of the same repo at once — **24 worktrees were live on 2026-08-04** — so "two checkouts at once" is the normal case rather than an edge case. - -**Proposed fix.** Allocate the block dynamically, assert contiguity at acquisition, and fail loudly if it cannot be obtained. Then remove the `flaky` marker, so a future collision is a red rather than a retry. Do not widen the retry count. +- What is the **engine-version floor**? The setting shipped 2026-07-10; `AOAG-DEPLOYMENT.md` carries no version vocabulary today. -**Related:** #340 (merge-queue serialisation — the other place this repo's parallelism outgrew a fixed assumption). +**Explicitly not this item:** the `db_lookup` gap. `transports/database.py` builds its own connection string and emits no `MultiSubnetFailover`, so a deployment reaching the same listener through the DATABASE connector needs the DNS-side configuration regardless of what this rig measures. Whether that connector should get the keyword is a separate owner decision — do not let a green result here be read as "the workaround is obsolete". -**Source:** found by a build session while re-verifying five rebased lanes, 2026-08-04. It attributed the immediate trigger to its own parallel harness rather than to the branches under test, and handed the underlying defect over because filing needs a number and a ranked-table row. +**Do not extrapolate from the AD lab.** `plan-11/w19-ad-lab-integration-validation.md` covers AD/Kerberos integration and records this as *"needs the SQL AG rig"*. Its note that "all shipped — this is confirmation, nothing is blocked" is true of the code and false of the documentation. -## 1021. The MFA enrollment confirm verifies the activating TOTP through a bool wrapper that discards the step, so it is never consumed (ASVS 6.5.1) +**Related:** #100 (the shipped setting), [`AOAG-DEPLOYMENT.md`](AOAG-DEPLOYMENT.md) §4.5 / §5.3, `messagefoundry/config/settings.py` (`multi_subnet_failover`, default `false`), `messagefoundry/store/sqlserver.py` (`connection_string`), [`CONFIGURATION.md`](CONFIGURATION.md). -> ✅ **Fixed 2026-08-06 — enrollment now consumes the activating TOTP step (`verify_totp_step` + `consume_totp_step`), mirroring the login path.** Value **6/10** · Difficulty **4/10** · _quick win_. `confirm_mfa_enrollment` proved the enrolling code through the `totp.verify_totp` bool wrapper, which computed the matched time-step then collapsed it to a bool, so the step was never recorded; with `last_totp_step` left NULL by `enable_totp`, the activating code would have remained usable on the login path for the remainder of its own step on first deployment. The confirm site now takes the matched step from `verify_totp_step` and requires `consume_totp_step` before minting recovery codes / `enable_totp`, so the step is single-use (ASVS 6.5.1) and enable stays atomic. +**Source:** filed 2026-07-31 while correcting two stale claims in `AOAG-DEPLOYMENT.md` (PR #99). The validation gap was visible only as a line in a plan-11 doc and was not tracked work — a closed item with outstanding validation, which is the shape that goes missing. -**Cluster:** Security / authentication. **Priority:** P2. **Verdict:** build (small). **Severity:** would leave a narrow second-factor replay window at enrollment on first deployment — bounded, not a bypass. +## 1012. ASVS gate summary line silently drops a verdict state: components sum to 344 against its own stated 345 -**Two facts combine, and the body needs both.** The confirm path discards the step (`auth/service.py:1979` calls `totp.verify_totp`; `auth/totp.py:150` computes the step then returns `... is not None`), and nothing seeds the high-water mark, so the discarded step is genuinely reachable rather than incidentally blocked: `enable_totp` updates only `totp_enabled`, `totp_enrolled_at`, `totp_recovery_codes`, `updated_at` in all three backends (`store/store.py:7752-7764`, `sqlserver.py:9095`, `postgres.py:6165`), leaving `users.last_totp_step` NULL, and the compare-and-set at `store/store.py:7824` accepts any matched step against a NULL mark. +> 🔢 **Filed 2026-08-04 — not started.** Value **5/10** · Difficulty **2/10** · _fill-in_. The gate's summary line prints five verdict states whose components sum to **344**, while the same line states a total of **345**. It omits `needs-review`. So the line cannot be reconciled against itself, and a reader who trusts it under-counts one state entirely. -**The replay target is the login path, not a second confirm.** Code `C` proven at `POST /me/mfa/confirm` would still be accepted by `POST /auth/mfa-verify` on a separate, password-authenticated session for the same account. `totp_skew_steps` defaults to `0` (`config/settings.py:1736`), so the window is the remainder of `C`'s own 30-second step — roughly 60 or 90 seconds only under the documented 1/2 opt-in. Do not size it as plus-or-minus-one step. `confirm_mfa_enrollment` also lacks a `totp_enabled` guard, so a second confirm would re-succeed, but that route needs a fresh action-bound password step-up (`api/auth_routes.py:408`) and is the lesser path — do not build the fix around it. +**Cluster:** Security / ASVS tooling. **Priority:** P3. **Verdict:** build (small). -⛔ **The replay guard already exists. Do not rebuild it.** `verify_totp_step` already returns the matched step and already clamps a tolerated fast-clock code down to the current step (`auth/totp.py:90-132`, SEC-014); `_verify_second_factor` already does verify-then-consume on the login path (`auth/service.py:2061-2073`); the atomic compare-and-set exists in all three backends (`store/store.py:7811-7828`, `sqlserver.py:9155-9177` with UPDLOCK/ROWLOCK, `postgres.py:6217-6231` with FOR UPDATE), declared at `store/base.py:1588`; and login-path single-use is pinned by `tests/test_mfa.py:139`. **The only thing missing is the call at the enrollment site.** Note also that `disable_totp` leaves `last_totp_step` untouched — that direction is conservative and must not be "fixed" by clearing it. +**Severity is low and worth saying why**, so nobody inflates it: no verdict is mis-scored, and the vault scorecard remains the record of record. The cost is that the summary is the line people quote — it was quoted as "the distribution" across a full session, and the omission propagated every time it was repeated. -**Difficulty 4, and the cost is test collateral rather than code.** The production change is about three lines: switch `:1979` to `verify_totp_step`, keep the step, and require `consume_totp_step` before activating — consuming **before** `enable_totp`/`mark_session_mfa_verified`/minting recovery codes, and treating a `False` as a failed confirm on the existing `auth.mfa_failed` phase=enroll branch. At least four tests confirm an enrollment then assert a live verify inside the same step and would go failing or intermittently failing: `tests/test_mfa.py:81-94`, `:147-157` (sharpest — it reuses the same code object), `:272-281`, and `tests/test_step_up.py:314-318`. The obvious remedy does not work: `tests/_totp_clock.py`'s `fresh_totp` guarantees headroom **within** the current step and cannot advance one, so each affected test needs restructuring rather than a CI sleep across a 30-second boundary. +**Why it matters more than a cosmetic count.** A count that does not reconcile with its own stated total is the tell for a missing category, and the check that would have caught it does not exist. This is the same shape as several defects found the same day: an instrument that answers a narrower question than the one asked, and reports success. -**Both operator surfaces reach this through the one service method** — `POST /me/mfa/confirm` (`api/auth_routes.py:403-427`) and `POST /ui/account/mfa/verify` (`messagefoundry_webconsole/routes/account.py:239-272`) — so fixing the service method fixes both and no route change is needed. +**Proposed fix.** Emit the missing state, and add an assertion that the printed components **equal** the printed total. The assertion is the durable half; the missing state alone would leave the next added verdict able to vanish the same way. -**Open question, not a blocker:** whether any security document states TOTP single-use in terms broad enough to be made inaccurate by this gap. `docs/BACKLOG.md:698` describes the per-user compare-and-set and is true as written. The vault scorecard was not readable from this checkout, so if 6.5.1 is scored fully met there, that cell needs re-validating against the code rather than being assumed still correct. +**Related:** the ASVS scorecard tooling under `docs/security/` in the vault (not tracked here — see [`SECURITY-DOCS-POLICY.md`](SECURITY-DOCS-POLICY.md)). Recorded in vault `d0c5736a` §2. -**Source:** found during the ASVS V6 re-verification, 2026-08-04, and adversarially re-verified against the code at `6e481c14` before filing. Confirmed as stated. +**Source:** handed over by the ASVS-cleanup session on 2026-08-04, which found it after quoting the line all day, and which could not file it because the ledger was held elsewhere. ## 1017. worktree_gate rule 3d has no ownership signal, so it denies a session removing a worktree it created itself @@ -5037,42 +4237,6 @@ The comment immediately above says *"Scope is deliberately the posture the requi **Source:** raised as RANK 1 by the vault drift-repair pass, 2026-08-04; mechanism re-derived and corrected against the code at `e0482aea` before filing. Held back from an earlier ledger pass precisely because the reported mechanism was unverified. -## 1025. Three `require_ui_step_up` routes emit PHI with no `phi=`, so they charge no per-actor read budget - -> ✅ **SHIPPED 2026-08-06 — the two content-search render paths brought under the per-actor read budget; the third route was already covered.** Value **5/10** · Difficulty **2/10** · _fill-in_. **AMENDED 2026-08-05 — scope corrected against the code before building.** The filing's premise (all three routes charge no read budget) does not hold: `search_messages`, `layered_search` and `browse_uploaded_file` each call `enforce_phi_read_pacing` in their own body — which the console executes when it invokes them directly — so every request that actually reaches a handler was already charged at the cited commit `e0482aea`. The real gap was only the console's SHORT-CIRCUIT renders (`GET /ui/messages/search` bare-form, `GET /ui/messages/search/layered` no-preset) that return *before* the handler runs. **AMENDED 2026-08-06 — mechanism corrected from a gate-level `phi=` to an inline branch charge.** A gate-level `phi=` on `require_ui_step_up` charges in the dependency, i.e. on *every* request, so it would have double-charged the criteria/preset path — which already charges in the handler — the exact double-count that excludes the uploaded route. Instead each search route now charges `enforce_phi_read_pacing` **inline on its short-circuit branch only**, so the bare-form / no-preset render spends a token while a real search still charges exactly once. `GET /ui/uploaded-logs/file/{file_id}` was deliberately left unchanged — it has no short-circuit and `browse_uploaded_file` paces every call, so any second charge would double-count the same budget (empirically the first browse would `429` at a budget of 1). **A missing rate limit, not a missing authorization check** — all three still gate on the right permission. Shipped: the two search short-circuit charges + the `require_ui_step_up` docstring corrected + `docs/SECURITY.md` and the webconsole CHANGELOG aligned to the true mechanism. - -**Cluster:** Security / PHI anti-automation. **Priority:** P2. **Verdict:** build (small). **Severity:** would leave three PHI-emitting console routes outside the per-actor read budget on first deployment, so an authorised-but-abusive actor could enumerate through them without hitting the 429 the sibling browse routes enforce. No unauthorised access. - -**Mechanism, verified at `e0482aea`.** `require_ui` declares `phi: bool = False` and throttles at `messagefoundry_webconsole/_auth.py:260` with `if phi and not auth.allow_phi_read(identity.user_id):`. `require_ui_step_up` builds its base as `require_ui(*permissions, allow_mfa_pending=True)`; unless `phi=` is passed through, the arm is unreachable. The three routes above pass nothing — `GET /ui/messages/search` is on `require_ui_step_up(Permission.MESSAGES_READ)`. - -**The plumbing already exists, so this is three call sites and tests.** #324 threaded `phi=` into `require_ui_step_up` (`_auth.py:498`, whose docstring records that `phi=True` "forwards to `require_ui`'s `phi` arm ... the same throttle the plain `require_ui(..., phi=True)` browse routes and the JSON `require_phi_read` routes charge") and used it on the edit route (`routes/core.py:612`). **Difficulty 2 is that inheritance** — before #324 this would have been the plumbing plus the call sites. - -**Copy the siblings that already do it right:** `routes/core.py:473`, `:483`, `:501`, each `require_ui(Permission.MESSAGES_VIEW_RAW, phi=True)`. - -**Related:** #324 (built the seam and the two edit routes; closed), #1027. - -**Source:** reported by the #324 lane rather than fixed in it, per the owner's settle that the lane thread `phi=` for its own route only and report the rest. Mechanism re-verified independently before filing. - -## 1027. The documented `pytest` command silently excludes the webconsole package, so a local green is not evidence about ~344 tests - -> ✅ **SHIPPED 2026-08-06 — the root `testpaths` now also collects `packaging/messagefoundry-webconsole/tests`, so a bare `pytest -q` from the repo root stops silently excluding the web console suite; the one webauthn-extra-dependent console test that lacked a guard (`test_webauthn_rp_fail_closed_legible`) now skips-with-reason when the optional `[webauthn]` extra is absent, so an extra-less local venv stays green.** Value **5/10** · Difficulty **3/10** · _fill-in_. Local developer-signal fix only — CI already covered the console via its dedicated `Web console tests (pytest)` step; the gap was that the documented local gate collected less than it appeared to. - -**Cluster:** Testing / verification integrity. **Priority:** P3. **Verdict:** build (small). **Severity:** no product effect; the defect is that the project's own verification instruction produces a green that is not evidence about roughly 344 tests, and CLAUDE.md §5 states a task is not done until it passes. - -**It is the documented command, which is what makes it more than a config default.** `CLAUDE.md:333` gives `QT_QPA_PLATFORM=offscreen pytest -q` as the way to run the suite, and `pyproject.toml`'s `[tool.pytest.ini_options]` sets `testpaths = ["tests"]`. Every session that followed the instruction measured a tree it believed was covered. - -**The evidence, and it is not hypothetical.** On 2026-08-04 `packaging/messagefoundry-webconsole/tests/test_webui.py::test_webauthn_rp_fail_closed_legible` was failing on `main` all day and no lane saw it. It surfaced only when one lane named both paths explicitly because it was editing `messagefoundry_webconsole/` directly — `pytest tests packaging/messagefoundry-webconsole/tests` returned `1 failed, 10681 passed, 851 skipped`. - -⚠️ **Not a CI gap — verified, not assumed.** CI runs `Web console tests (pytest)` as a separate required step and installs the extra the failing test needs (`.github/workflows/ci.yml:250` installs `-e ".[dev,harness,fhir,dicom,x12,xml,webauthn]" -e packaging/messagefoundry-webconsole`, and `:245` records that `[webauthn]` is there "so the passkey ceremony tests run real `verify_*` assertions"). So PRs have been merging on real coverage. **The gap is local only**, which is why it went unnoticed: nothing red ever reached anyone. - -**Difficulty 3 because the naive fix reds every local run.** Adding the packaging path to `testpaths` makes that same `[webauthn]` failure the default local experience, since worktree venvs bootstrap a narrower extra set than CI. So the item is really "make local coverage honest", and the options interact: widen `testpaths` **and** make the webauthn tests skip-with-reason without the extra; or leave `testpaths` and correct `CLAUDE.md` to document both paths; or have the venv bootstrap install the extra. Whichever is chosen, ⛔ **a skip must announce itself** — this project's own standard is that a skip reading as a pass is the failure being fixed here, so do not trade a silent exclusion for a silent skip. - -⭐ **The general shape, worth keeping when this is fixed.** *A citation nobody has broken yet and a citation nobody has noticed is broken look identical in a grep; only the change that breaks it can tell them apart.* The same is true of a test path: an excluded suite and a passing suite look identical in a green summary line. The fix is not to remember, it is to make the exclusion visible. - -**Related:** #1018 (guards that go quiet), #344 (the two test steps sharing one budget), ADR 0158. - -**Source:** found by the #324 lane on 2026-08-04 when it named both pytest paths for a webconsole-touching change; the CI-coverage half was flagged by that lane as an inference and verified against `ci.yml` before filing. - ## 1028. Async session mail: close the red-team defects before wiring > 🚧 **Filed 2026-08-05 — in progress.** Value **6/10** · Difficulty **5/10** · The async session-mail prototype ([ADR 0161](adr/0161-async-session-mail-for-unreachable-peers.md)) is deliberately **not wired**, because an adversarial pass found eight defects — four of them severe, including a mutual-exclusion primitive that **does not exclude**. This item is the work that closes them and the gate on wiring. @@ -5106,26 +4270,6 @@ The comment immediately above says *"Scope is deliberately the posture the requi **Source:** adversarial review of the prototype by a red-team pass on 2026-08-05, before any wiring. The `File.Move` behaviour was measured in response to that pass rather than inferred from it, and the measurement retired the fix the review itself had proposed. -## 1029. `/simplify` shipped as a local skill with no entry in the quality-standards record, so the one review tool that edits the tree had no written placement or scope - -> ✅ **SHIPPED 2026-08-05 — the documentation is the whole deliverable.** Value **3/10** · Difficulty **1/10** · _quick win_. `/simplify` is now recorded in [`docs/Code_Quality_Standards.md`](Code_Quality_Standards.md) §5.1 as a local, human-invoked **advisory** review that **applies** its fixes, ordered before the `ruff` / `mypy` / `pytest` quartet, with the justified-duplication carve-outs written down. A new §5.1, a scoping clause in §5's intro, a mapping row in §6, and a `Before you verify` heading in `CLAUDE.md` §5. - -**Cluster:** Documentation / quality-control record. **Priority:** P3. **Verdict:** build (small). **Severity:** no product effect and no security effect. The gap was in the record: the quality-standards document enumerated five measurement gates and named no review tool that rewrites code, so the one ordering constraint that matters and the scope limits that already follow from earlier decisions were unwritten and uncitable. - -**What the record now says.** §5.1 is a new subsection and the single home for the tool. §5's placement table is **unchanged at five rows** — an earlier draft added a sixth and was reverted, because a row declaring itself "not a gate" contradicted both that table's `Gate` column and the §5 heading, and forced the same caveat into three other places. §5's intro instead gains one scoping clause naming §5.1 as a review tool deliberately not among the five. §6's companion-mapping table lists it in the same local, human-invoked, advisory tier as `/code-review` and `/security-review`, with the one difference that separates them stated **once**: those two report findings a human arbitrates, this one applies edits. - -**No status is claimed for it, and that is deliberate.** Every other entry in this document names a tracked artifact and a pull request. `/simplify` ships with Claude Code rather than with this project, so there is no `.claude/` entry, pin, or other artifact in the checkout to score — **Built** is therefore a claim the document explicitly declines to make, citing the Appendix A honesty taxonomy. §4.0's liveness rule does not reach it either, because there is no green check to trust. - -**The ordering is a consequence of the report-versus-apply difference, not a convention.** A tool that applies fixes, run after the quartet, would mutate the tree the quartet had just certified. `CLAUDE.md` carries it as a `Before you verify` heading placed *ahead of* the verification-expectations list rather than inside it — it is a mandated pre-step, not a gate, and "a task isn't done until these pass" cannot govern something that emits no pass or fail. - -**The carve-outs are the part most easily lost, and they are an open class.** §5.1 records **at least** these deliberately-justified duplications as out of scope: the SQL Server / Postgres store-backend parity that signal 9's clone detection already whitelists, and the `messagefoundry/anon/` package vendored to `tee/anon/` under [ADR 0030](adr/0030-anonymization-test-harness-tee.md), which signal 9 cannot see at all because its `jscpd` scan covers `messagefoundry/` only. The defensive branching tolerant HL7 parsing requires (`CLAUDE.md` §8) is recorded separately as a signal 11 *complexity* concern rather than a duplication one. Nothing the tool produces certifies quality (§4.1); the maintainer owns every applied edit under the *reject code you cannot explain* floor. - -**Difficulty 1 because nothing was built.** The skill already existed and is unchanged; the deliverable is a subsection, a table row, a heading and a clause. It is filed closed rather than skipped so the placement decision has a number to cite. - -**Related:** #1027 (the quartet this ordering sits in front of, and the same class of defect — a verification instruction that does not say what it actually covers), #1006 (an advisory gate from the same rubric), #1000 (gate liveness, the rule §5.1 explicitly records as not reaching a non-gate). - -**Source:** filed alongside the documentation change itself, 2026-08-05, and rewritten before filing because the first draft described a structure that was subsequently reverted. Every claim above was read from the working tree at commit `17c52129` rather than recalled: §5.1 at line 221, the five-row gate table, the §6 row at line 242, `CLAUDE.md`'s heading at line 288, and both `Built` mentions confirmed to be negations. The same change removed all 41 status glyphs from that document (rubric v0.12) and marked its pull-request citations as `PR #N`, the bare form having already resolved to the wrong item for `#1020`. - ## 1030. Non-cp1252 characters in source are gated one file at a time, so the class keeps recurring > 🔢 **Filed 2026-08-05 — not started.** Value **6/10** · Difficulty **4/10** · _quick win_. At least two gates exist and each covers exactly one thing: `tests/test_cli.py:43-58` asserts one string (`messagefoundry --help`) is cp1252-encodable, and `tests/test_announce_hook.py:810` asserts one file (`scripts/hooks/announce-session.ps1`) is ASCII-only. Neither generalises, so a glyph reaching `print()` from any other script is caught only by a human reading the diff. @@ -5148,55 +4292,6 @@ The comment immediately above says *"Scope is deliberately the posture the requi **Source:** raised by the `scripts/` glyph sweep on 2026-08-05, then rewritten after an adversarial pass refuted the first draft's "exactly one gate exists" and its `errors='strict'` mechanism. The `--help` crash and the stream-handler values were measured, not inferred. -## 1031. The STEP4 bench doc restates the stage_residency docstring in the glyphs its source shed, and carries emoji - -> ✅ **SHIPPED 2026-08-05.** All 101 non-cp1252 characters removed from `docs/benchmarks/STEP4-bracket-and-littles-law.md` — the **whole file**, not just the §5.2 block enumerated below, which was written as a floor and was one. U+2264/U+2265/U+2212/U+2192/U+2190/U+2260/U+21D2 to their ASCII forms; U+03BB/U+03C3 to `lambda`/`sigma`; U+2261 to `==`; U+2227 to the word `AND`; the four U+26A0 + U+FE0F pairs to the word `WARNING`. U+2248 became the file's **own** bare-tilde idiom (`~62 ms`, `rho ~0.23`) rather than `~=` — `~=` is the PEP 440 compatible-release operator everywhere else in `docs/` and means NOT-EQUAL in MATLAB and Lua, which would have inverted the verdict rows at lines 373-375. U+00D7 deliberately KEPT (14 occurrences): it is cp1252-representable typography, not a glyph, and the source keeps 4. - -**Cluster:** Docs / consistency. **Priority:** P4. **Verdict:** build (trivial). **Severity:** none operationally. It is a documentation defect: a reader comparing the doc to the tool sees two renderings of one definition and cannot tell whether the difference is meaningful. - -**Where — lines 414-425, and at least these.** U+2264 twice and U+2212 once on line 416 (`N(t) = #transformed<=t - #delivered<=t`, which the source now writes in ASCII, matching what `stage_residency.py:557` already used); U+2192 on 417; U+2248 on 422, in the sentence the source now reads as "N is about 8, therefore the lanes are saturated"; U+03BB on 425; and U+26A0 + U+FE0F on 421 and 425. Enumerated by scan rather than by eye, but treat it as a floor and re-scan the range. - -**Do not "fix" U+00D7 — the source keeps it.** `stage_residency.py` still contains four multiplication signs, including on the same sentence as doc line 425. It is cp1252-representable and out of scope for §11. Converting the doc's copy would *create* a divergence rather than remove one. - -**The source is cp1252-safe, not ASCII.** It retains 70 em dashes and those four multiplication signs. Em dashes, ellipses and section signs in the doc are cp1252-representable typography and stay. - -**Nothing machine-compares them, which is the point.** No gate reads both, so this did not go red and will not. It is the shape #1030 exists to catch, and if #1030 lands with docs in scope this closes as a side effect — check that before doing it by hand. - -**Related:** #1030 (the missing gate that would have caught this), #1027. - -**Source:** found by the completeness pass over the `scripts/` glyph sweep on 2026-08-05; the codepoint enumeration was corrected by an adversarial pass that caught the first draft claiming U+00D7 as a divergence and missing the U+26A0/U+FE0F pair entirely. - -## 1032. `worktree_gate` Rule 3b prints a `new.ps1` command that `new.ps1` rejects - -> ✅ **SHIPPED 2026-08-05 — merged as PR #214 (`fdaf53f7`).** Value **6/10** · Difficulty **3/10** · _fill-in_. The Rule 3b deny's escape hatch could not be executed for the case that triggers it: it interpolated a slash-bearing branch name into a parameter that forbids slashes, which is 143 of 196 local branches. Reproduced by running it, not by reading it. `new.ps1` gained a `-Branch` parameter distinct from `-Name` and the rule now emits both. The same work closed a refname **command injection** in that deny text (#1040) and a hijack **bypass** the first attempt introduced — rule 3b deferred to a git guard that `--ignore-other-worktrees`, `--detach` and `-d` all switch off, on both `checkout` and `switch`, so the fix is an allowlist (deny on ANY flag) rather than a list of known bypasses (#1039). Verified in main: `ConvertTo-WorktreeSlug` present in `scripts/hooks/worktree_gate.ps1`. - -**What.** `scripts/hooks/worktree_gate.ps1:388`, inside the Rule 3b deny ("BLOCKED: would switch a LINKED WORKTREE onto the existing branch"), tells the caller to give the branch its own worktree with: - -``` -pwsh -NoProfile -File $newHint -Name $dest -``` - -`$dest` is the **branch** name. `scripts/worktree/new.ps1:26` validates `-Name` against `^[A-Za-z0-9._-]+$`, which every slash-bearing branch fails. Measured 2026-08-05: a branch of the form `claude/-` is REJECTED while the bare `` component is accepted, and **140 of 193 local branches carry a slash**. The gate's motivating case is a branch that already exists — which is exactly why it carries a `claude/` prefix — so the escape hatch fails in the default case, not an edge case. - -**Why it survived.** The other three sites (`:411`, `:685`, `:794`) print the placeholder `-Name `, which is valid. `:388` is the only interpolating one, so a grep for the common form finds three healthy instances and misses the defect. Two independent readers hit exactly that; the one who found it had run the command and held the failure in hand first. - -**DO NOT fix this by relaxing the ValidatePattern.** `$Name` does two jobs and the pattern is load-bearing for the first: - -| line | use | -|---|---| -| `new.ps1:43` | `Join-Path $Parent "$RepoName-$Name"` — a **path component** | -| `new.ps1:58`, `:72`, `:86`, `:97` | `git branch --list` / `worktree add` / the `mefor-home-branch` marker — a **ref** | - -A slash satisfies git as a refname but makes `Join-Path` build a nested directory. Measured: a `claude/` branch yields `MessageFoundry-claude\` instead of a sibling `MessageFoundry-`, so the worktree lands one level deeper than every other one. Loosening the pattern alone converts a **loud correct failure into a quiet wrong success** — the worse direction of error. - -**Preferred fix.** Add a `-Branch` parameter distinct from `-Name` (name = directory component, branch = ref), defaulting `-Branch` to `-Name` so every existing caller is unchanged; then `:388` emits `-Branch $dest -Name `. **Fallback** if a new parameter is unwanted: stop printing a command that cannot work, and print the supported procedure instead. - -**Verification this item must demand.** A test that **executes the string the gate prints**, not one that asserts a copy of it — a test hard-coding the expected hint passes throughout this defect, which is the "guard tests a copy of the rule" trap and is how it survived. It must also assert the resulting worktree directory is a **sibling**, since that is the regression the current validation prevents and that a naive fix would introduce. - -**Related:** #1030 (the missing general gate), #1027. - -**Source:** found by session `sleepy-villani-df328d` while gate-blocked twice, correctly, from another session's branch; reproduced independently by the coordinator against `new.ps1:26` and `Join-Path`. A Claude Code task chip (`task_fb78da2c`) covers the same defect but carries no allocated number and will not survive the session, so this ledger entry is the durable record. - ## 1033. The rubric cites its own signals as `#N`, and six of those numbers are real backlog items > 🔢 **Filed 2026-08-05 — not started.** Value **4/10** · Difficulty **2/10** · _fill-in_. [`docs/Code_Quality_Standards.md`](Code_Quality_Standards.md) refers to its own eleven rubric signals as `#6`, `#7`, `#9`, `#10`, `#11`. In this corpus a bare `#N` reads as a backlog item, and six of those numbers **are** backlog items. Owner ruled 2026-08-05 that they get disambiguated. The four-digit PR citations in the same file were already fixed (PR #209); this is the short-number half that was deliberately left out of scope there. @@ -5227,25 +4322,6 @@ Resolved against both ledger files with `parse_items`: **`#3` is an OPEN item to **Source:** raised by session `sleepy-villani-df328d` while sweeping the four-digit citations, and correctly kept out of that PR's scope. Owner ruled on it 2026-08-05. Counts here were re-measured against 780ee1d9 with a self-tested pattern after an unverified one reported zero. -## 1034. The pre-push shim fails OPEN when python is not on PATH, so the push guard silently does not run - -> ✅ **SHIPPED 2026-08-05 — merged as PR #215 (`09c6fe8e`) and PR #217 (`e75cff02`).** Value **7/10** · Difficulty **3/10** · _fill-in_. The headline defect and both "adjacent gaps" below are fixed. #215: both generated shims now refuse instead of exiting 0 when neither `python` nor `python3` resolves, and name `--no-verify` so a fail-closed gate does not get "fixed" by deleting it. #217: `MEFOR_ALLOW_DIRECT_PUSH` is scoped to the protected-branch guard alone, so it no longer disarms the namespace and content guards it was never named for; and a tip tree the guard cannot READ is refused rather than assumed clean, because "there is nothing there" and "I could not look" are different facts. Proven against the pre-fix code rather than asserted: the old shims exit 0 with no interpreter on PATH, and the old guard permits both a branch and a tag carrying `docs/security`. **What did NOT ship is this item's own prescription** — "the durable answer is server-side" is measured DEAD on both halves (a push ruleset returns `422 Source public repos cannot have push rules`; `enforce_admins` governs protected branches and so cannot see a feature branch). That residual, and the fact that no server-side content control exists here at all, is **#1056** — this item is closed on its title, not on that finding. - -**What.** The shim is generated by `scripts/coord/install-git-hooks.ps1` and shared by every worktree through `core.hooksPath`. When it cannot find python it prints its notice to stderr and returns 0, allowing the push. That is the correct posture for a *workflow* guard that should not wedge a developer, and the wrong one for the only remaining control on a publication path — the same fail-open-versus-fail-closed distinction the security standards already draw between the git-staging guard and the engine's bind guard. - -**Why it matters more since 2026-08-05.** `push_guard.py` gained two further checks that day: a namespace allowlist (refusing a `--mirror`-shaped push) and a tip-tree check (refusing a ref carrying `docs/security`). Both are defeated by the same fail-open, so the shim now switches off three guards rather than one, and the failure is silent in the noisiest possible place — a terminal line above a successful push. - -**Two adjacent gaps in the same class**, worth deciding together rather than separately: - -- A **fresh clone or a newly created worktree has no hook at all** until `install-git-hooks.ps1` runs. Nothing prompts for it. -- `git push --no-verify` and `MEFOR_ALLOW_DIRECT_PUSH=1` skip every check by design, and the latter returns 0 before any guard runs despite reading like it permits one specific thing. - -**A client-side hook cannot be the sole control, and that is the real finding.** Any fix here reduces the likelihood of an accident; it does not close the path. The durable answer is server-side — re-enabling `enforce_admins`, or a push ruleset — with the shim hardened as defence in depth rather than as the boundary. Whatever is decided, no prose may describe the hook as a security boundary; its own docstring already refuses that framing and should keep refusing it. - -**Related:** \#1032 (same file family, and the same shape of a remediation that cannot execute), PR #209. - -**Source:** surfaced 2026-08-05 while adding the two new guards, from the observation that a guard everything else leans on can be switched off by a missing interpreter. Held for the owner: another session has it as analysis only, with no build decision taken. - ## 1035. Gate remediations interpolate an unquoted `-File` path into a command the reader is told to run > 🔢 **Filed 2026-08-05 — not started.** Value **5/10** · Difficulty **2/10** · _quick win_. Every `pwsh -NoProfile -File $x` the gate prints interpolates a governed-root path with no quoting. A root whose path contains a space produces a command that cannot run. Latent today only because the single allowlist entry has no space in it. @@ -5699,26 +4775,6 @@ against the gate **as it will ship**, not as it is. **Source:** ASVS 5.0.0 V16 re-verification, 2026-08-05. Detail in the maintainer-internal ASVS V16 chapter report. -## 1041. Rule 3d tells a session removing its OWN worktree that it belongs to another session - -> ✅ **SHIPPED 2026-08-05 — the false premise is gone and the cwd check is now made rather than argued for.** Value **4/10** · Difficulty **2/10** · _fill-in_. Rule 3d resolves the victim's toplevel and the session's own and compares them, so a session acting on the tree it is standing in gets a deny that says exactly that, instead of being blamed on a session that does not exist. **Scope, stated because the item's title is broader than the fix:** this establishes *"this IS the tree you are standing in"*, which is the only ownership fact available here. It does **not** establish the converse — a worktree that is not yours to stand in may still be nobody's, and the rule still has no occupancy or authorship signal to tell an abandoned tree from a live one. The sibling deny therefore still refuses, and now says it cannot tell rather than claiming it knows. A caller who *created* a worktree and removes it from elsewhere is still refused; that case is unaddressed and needs an occupancy signal, not a text change. Three regression tests, each confirmed failing against the pre-fix gate first — the sharpest being that the two denies were previously **byte-identical**, which is the defect in one line. Original filing follows. `scripts/hooks/worktree_gate.ps1:528` justified rule 3d with *"git refuses to remove the worktree you are STANDING in -- so a `worktree remove` that reaches git is, by construction, aimed at somebody else's."* The gate is a **PreToolUse** hook, so it runs **before** git: git's refusal never happens, the inference is never tested, and the deny at `:563` asserts *"belongs to ANOTHER SESSION ... so this one is not yours"* for every governed worktree including the caller's own. - -**Cluster:** Session-drift controls / refusal accuracy. **Priority:** P3. **Verdict:** build (small). **Severity:** no data loss — the deny is *correct as a decision* and it does prevent an accidental self-deletion. The defect is entirely in what the text tells the reader to do next, which CLAUDE.md §11 treats as a correctness property: *"a gate that misdescribes the thing it blocked trains people to route around it"* (recorded at `worktree_gate.ps1:646` for the sibling case #308 already fixed). - -**Reproduced first-hand on 2026-08-05, not reasoned from source.** A session standing in a linked worktree under `/.claude/worktrees/` ran `git worktree remove ` and received rule 3d's refusal verbatim: *"acts on a worktree of `` that belongs to ANOTHER SESSION -- git refuses to remove the worktree you are standing in, so this one is not yours."* Both clauses are false in that run. Nothing was deleted, because the hook denied the whole command before git executed — which is also precisely why the premise cannot hold. - -**Why the inference fails, stated once.** The premise is a claim about what reaches git. A PreToolUse hook decides *whether anything reaches git at all*, so it can never observe the state its own premise depends on. Any rule that defers to a downstream layer's guard has this shape; here the deferral is unconditional and the guard is unreachable. - -**The remedy text compounds it.** The refusal closes with *"I want to remove the worktree `` and I need you to confirm it is not in use."* For the caller's own worktree that sends the operator to verify a fact that is false by construction — the worktree is in use by the session asking. The other two suggestions (`prune-merged.ps1`, `git worktree list`) stay correct. - -**The fix is local and the value is already computed.** Rule 3d resolves `$victimCmp` at `:554` for its governed-root test at `:557`. Comparing it against the session's own toplevel — `git -C $cwdRaw rev-parse --show-toplevel`, the same call rule 3b already makes — splits the two cases: a peer's worktree keeps the current text, and the caller's own gets an accurate one (git will refuse this itself; if you mean to discard the worktree, that is the user's call from a plain terminal). Difficulty 2: one comparison, one branch, and a regression test per branch. Do not simply *allow* the self case — the deny is the right decision, and blocking an accidental self-deletion is worth keeping. - -**Do not fix by deleting the premise sentence.** It is load-bearing documentation of *why* rule 3d has no cwd check, so removing it leaves the missing check unexplained. Replace it with what is actually true: git's guard is unreachable from here, therefore the rule must decide ownership itself. - -**Related:** #308 (the same defect class — a refusal describing something the reader cannot act on — fixed for the nested-worktree subpath), #1018 (guards that go quiet), ADR 0158. - -**Source:** reported by a concurrent session while it was fixing rule 3b's remediation text, verified independently against the source rather than relayed, then reproduced live by accident when a second session ran the command against its own worktree. Filed by the session that verified it, which is not building it; the reporting session offered to take it if the owner scopes it there. - ## 1056. No server-side content control exists for this repo, so the client-side push guard is the only prevention > 🔢 **Filed 2026-08-05 — not started.** Value **7/10** · Difficulty **6/10** · _decide_. Successor to #1034, which is closed on its title. That item's stated remedy — "the durable answer is server-side" — is **unavailable**, measured rather than inferred. So the only thing preventing `docs/security` from reaching the public remote is a client-side hook that `git push --no-verify` skips and a fresh clone does not have until an installer is run by hand. That is the posture; the question is whether it is acceptable. @@ -5818,40 +4874,6 @@ The literal spellings are all caught, including the newline form. Only the indir **Source:** found 2026-08-05 by a session doing unrelated branch cleanup, from noticing that a rule 3b deny named a worktree the command was not going to act on. Verified against both the source gate and the installed one before filing. The reporting session did not build the fix, did not exploit the bypass, and used literal paths for its own subsequent work rather than the hole it had just found. -## 1060. `alloc.ps1` records the owning worktree from the current directory, so an absolute-path invocation misattributes it - -> ✅ **SHIPPED 2026-08-06 — every `git` call in both allocators is anchored to the script's own checkout, and the defect was larger than filed.** Value **5/10** · Difficulty **2/10** · _quick win_. `$repo` now comes from `git -C $PSScriptRoot rev-parse --path-format=absolute --show-toplevel`, and every subsequent call takes `-C $repo`. **`git -C`, not `Split-Path`:** the recorded `worktree` value is *compared* — by `ledger_check.py:227` and by `-List` — so its string form is part of a contract, and `--path-format=absolute` keeps writing the forward-slash form every claim already on disk carries. **The filing named one of four cwd-derived reads.** Also wrong, and measured on the pre-fix code: the `$branch` recorded with the claim was the *caller's* branch; the floor's boundary was parsed from the **caller's** `scripts/hooks/ledger_check.py`; and the floor's working-tree term — the one whose job is to catch a number written but committed **nowhere** — read the **caller's** `docs/BACKLOG.md`. That last one is not friction: a number drafted in the target worktree was invisible to the sweep and free to re-issue, which is the collision this script exists to prevent. **`scripts/coord/claim.ps1:54` carried the same construct and was never filed** — found by inspection while fixing this, fixed in the same commit; its enforcing hook `claim_check.py` reads the repo from cwd and is *right* to, because a commit hook's cwd **is** the committing worktree. Hook right, tool wrong, and only the tool can be invoked from elsewhere. The item's cheap second half — printing the recorded worktree at allocation time — was **already built** (`claimed by:`); what was missing is a note when the shell is standing somewhere else, which is now printed. Tested by the **divergence** with `-ShowFloor`, so no numbers were burned: two temp checkouts drafting different numbers, the allocator invoked by absolute path from the other one. The negative control was run, not assumed — reverted, the same test reports `floor : 7777`, `boundary : 1900` and a watermark under `Caller/.git/`, all three signals pointing at the wrong tree. Original filing follows. `scripts/coord/alloc.ps1:51` took the owner from `git rev-parse --show-toplevel`, which resolves against the **current directory** rather than the script's location. Invoke it by absolute `-File` path from a different worktree — which is how a session with several worktrees naturally calls it — and the allocation is recorded to the caller's worktree while the commit comes from another. The ledger gate then refuses that commit correctly, but far away from the cause and with a message about the wrong thing. - -**Cluster:** Session coordination / ledger integrity. **Priority:** P3. **Verdict:** build (small). **Severity:** no data loss and no security effect — the ledger gate **fails closed**, which is why this is a friction defect and not a correctness one. Nothing invalid lands; a valid commit is refused. - -**SEVERITY CORRECTION, 2026-08-06, made while fixing it.** The paragraph above is right about the *recorded owner* and wrong about the item as a whole, because the filing looked at one line and the defect was in four. The ledger gate does fail closed on a misattributed claim — but the floor's **working-tree term** was cwd-derived too, and that term has no gate behind it. Its job is to see a number written to `docs/BACKLOG.md` and committed **nowhere**; reading the caller's tree makes a number drafted in the *target* worktree invisible, so the allocator hands it out as free and two items end up sharing it in the same tree. Both are then owned by that worktree, so `owns()` passes and the ledger gate never fires. That is the exact silent collision the script's own docstring says it exists to prevent, arrived at through the script. The window is narrow — anything committed on any ref is still caught by the all-refs term — but it is a correctness hole, not friction, and it was reproduced in the negative control rather than reasoned about. - -**AND THE FIX CONVERTED TWO SANDBOXED TESTS INTO WRITERS ON THE LIVE REGISTRY, which is the part worth remembering.** `tests/test_coord_claim_{refresh,liveness}.py` ran the **real** `scripts/coord/claim.ps1` with `cwd` set to a temp repo — scoped to a throwaway registry *purely by ambient cwd*, and one of them said so in a docstring: *"it scopes itself to the cwd's repo"*. The moment the script stopped consulting cwd, the passing half of the run wrote real claims into this clone's shared registry (two strays, removed by hand). So a cwd-dependence that looks like a defect in the tool can be load-bearing **isolation** in its tests, and removing it is a change to both. `tests/test_ledger_check.py` already staged a copy of `alloc.ps1` inside its fixture, which is exactly why it was the one file that did not break — the pattern existed and the two claim files were the outliers. Both now stage and commit the script into the fixture, so the sandbox is structural rather than ambient. Anyone fixing the remaining instances of this class (#1057, #1059) should check what their tests are isolated *by* before changing what the code reads. - -**Reproduced 2026-08-05, twice, by accident.** A session ran `pwsh -NoProfile -File /scripts/coord/alloc.ps1 -Kind backlog` from worktree A while intending to commit from worktree B. `alloc/backlog/1058.json` recorded `"worktree": "<...>/trusting-wu-c2e6d5"`. The commit from `MessageFoundry-gate-deferrals` was then refused: *"BACKLOG item #1058 was not allocated to this worktree"* — true, unhelpful, and pointing at the allocator rather than at the invocation. Re-running with the shell actually inside the target worktree produced `1059.json` with the right owner and the commit went through. **#1058 is an abandoned hole**, which is the sanctioned outcome (`alloc.ps1`'s own docstring: *holes are free, collisions are not*). - -**The fix is small and there are two defensible shapes.** Either derive the repo from `$PSScriptRoot` so the allocator is anchored to the checkout it lives in — matching what `new.ps1` and `remove.ps1` already do — or keep the cwd behaviour and **say so at the point of use**, printing the recorded worktree in the `ALLOCATED` output so the mismatch is visible immediately rather than at commit time. The second is weaker but nearly free, and the two compose. Prefer anchoring: an allocator invoked by absolute path is being told which checkout to act on, and it should not then consult a different one. - -**Do not fix by making the ledger gate more lenient.** Its refusal is correct and is the only reason this was noticed at all. The defect is that ownership was recorded wrongly, not that it was enforced. - ---- - -**THE SHARED PREMISE, which is larger than this item and is why it is worth reading here.** Three independent mechanisms in this repo assume, silently, that **where a command runs is where the caller is**: - -- **This item.** `alloc.ps1` resolves the owner from the current directory, not from the path it was handed. -- **#1059.** The worktree gate resolves a command's target as a literal string against the session's cwd, so a path arriving through a shell variable falls back to the caller's own worktree — and a command aimed at the shared primary is allowed. -- **#1057.** `occupancy.ps1` places sessions by cwd, so it cannot see a session writing into a worktree by absolute path from elsewhere. Measured on this repo: **0 occupants reported for a worktree that had been committed to a minute earlier.** - -`occupancy.ps1` already discloses the rate: **a session acting on a worktree by absolute path from elsewhere is 29% of writes on this repo**, by the project's own measurement. So the premise is not merely unstated, it is false about one write in three. - -**All three fail silently, and all three fail in the benign-looking direction** — a deny naming the wrong worktree, an owner recorded as the wrong worktree, an occupancy of zero for a worktree in active use. None raises. Each looks like a working answer. - -**All three were found by accident, none by looking**, which is the part that should not be trusted. Three instances is a coincidence-sized sample, and the honest next step is a targeted sweep for the shape — anything resolving a target from `--show-toplevel`, `getcwd`, or an unqualified relative path *when it was handed an explicit one* — which either produces a fourth concrete instance or shows three was the whole set. That is deliberately **not** filed as a theme item: "three mechanisms share a premise" has no fix and no closing condition, and would sit open describing something true. The premise is also recorded in [`docs/WORKTREES.md`](WORKTREES.md), so it outlives this item's closure. - -**Related:** #1059 (the gate instance, and the severe one), #1057 (the occupancy instance), #1000 (all three are green because they cannot see). - -**Source:** found 2026-08-05 while filing #1059, when the ledger gate refused a commit whose number had just been allocated successfully. Filed as the concrete defect rather than as the pattern, on the argument that a near-duplicate of an already-owned class dilutes the ledger — the same argument this session used earlier to decline filing a sibling to #1000. - ## 1061. Rule 3c fails open when the primary is named by a relative path, disarming every worktree's commit gates > 🔢 **Filed — a SECOND fix was WRITTEN and then REJECTED BY VERIFICATION (2026-08-06). ⛔ DO NOT SHIP IT EITHER. Four independent verifiers each returned a DIFFERENT blocker: at least FIVE new fail-opens and TWO new false-deny classes against the gate live on 57 worktrees, three proven end-to-end to disarm the commit hooks, two of those on keys the rule names explicitly (`includeif.`, `core.hooksPath`). Its 420/420 green suite could not see a single one of the four blockers. Same root cause as round 2, one layer down: structured parsing narrower than the regex it replaces. Patch banked, NOT COMMITTED, NOT INSTALLED (2026-08-06). ⛔ THE GATE GOVERNING THIS MACHINE IS UNCHANGED — still commit `a67838d2`, blob `3e7db362`, 57 worktrees — so this item is OPEN against what is actually running. The FIRST fix (round 2) was verified and REJECTED and its patch is banked, not in the tree; read nothing below as a closure claim, and re-measure before citing any of it.** **What the second fix is, and why it is a different object.** It is MINIMAL-FROM-COMMITTED, not a second parser: `+484/-74` against the committed gate versus the rejected patch's `+616/-96`. The rejected patch replaced this rule's broad regex matching with structured token parsing, and every place the parser turned out NARROWER than the regex became a hole — the git 2.46+ `git config set` form, a scope flag counted in any position, nine wrapper spellings (`(`, `$(`, `exec`, `eval`, `xargs`, `timeout`, `winpty`, `then`, `find -exec`), and a `--file` compared as TEXT. The second fix keeps the regex base and carries across only three of that work's ideas (sub-command splitting, `rev-parse --absolute-git-dir`, and `--file` retargeting), none of which needs a tokeniser. **Verified in both directions, which is the part that was missing before:** the new deny cases were confirmed RED against the REJECTED PATCH — not against the committed gate, which already denies them, so recording them as red-first-against-HEAD would have been false and would have produced a suite blind to exactly that regression class. **Still open on the second fix, measured and named:** #1067, #1069's residual, #1070, #1071, #1072. **On THIS item specifically:** the second fix keeps the rooting this item filed and adds `rev-parse --absolute-git-dir`, so the relative spelling still denies and a junction spelling of the same repository now denies too — the latter was a residual of this item's own mechanism and was measured ALLOW on the committed gate. Its `-C`/`cd` composition is unchanged in direction, and the false deny this banner records below (`cd && git -C . config …`) is still an ALLOW. The drafted assessment follows unedited: **THE FILED RELATIVE-PATH SPELLING, and the three defects this banner used to hold open against itself are now MEASURED CLOSED (#1065, #1066).** The target is resolved against the session's cwd before git is asked anything, and an unresolvable target no longer means "not governed". **What THIS item fixed is still only the spelling it filed** — everything wider was #1065/#1066's work, and their banners say what is and is not closed there. **The three follow-ups this banner previously recorded, re-measured against the current file with the hook subprocess cwd set equal to the payload cwd:** the surviving relative-path fail-open `cd ../../.. && git -C ../MessageFoundry config core.hooksPath /dev/null` now **DENIES**, because the resolver COMPOSES a `cd` with a following relative `-C` instead of preferring one (a real shell resolves the `-C` against the post-`cd` directory, measured); `git config --global alias.foo bar` now **ALLOWS** in every spelling, excluded before any path is resolved, so the deny text no longer contradicts its own closing bullet and no longer advises a remedy that also denies; and `cd && git -C . config core.hooksPath /dev/null` now **ALLOWS** instead of denying while naming the primary. ⛔ **This still does not say rule 3c is unbypassable** — see #1066 for what remains open by construction. Value **9/10** · Difficulty **3/10** · _do it_. Rule 3c now roots the target token to an absolute path against `$cwdRaw` first, then resolves the common dir against **that**, so the relative spelling denies exactly like the absolute one. The rooted path is also what goes to `git -C`, via a new `Get-FullPathRaw` — `Get-ComparablePath` split in two so there is still exactly one definition of "resolve this path", with the lowercasing tail removed for the caller that shells out (a lowercased path passes on Windows and silently misses the real directory on a case-sensitive filesystem, which this file already warns about twice). **The two failure conditions are now different and are answered differently**, which was the substance of the fix rather than the arithmetic: a target that cannot be resolved at all **denies** — nothing has been asked of git, so nothing has said the repository is ungoverned, and treating that silence as "not governed" is precisely how this shipped — while git **answering** "not a repository" on a resolved target still **allows**, unchanged and still pinned by `test_a_non_repo_cwd_fails_open`. **A second fail-open closed with it**, found while fixing this one and not in the original filing: `& git -C ` resolved against the **hook process's** cwd rather than the session's, so a relative path naming a **linked** worktree made git exit 128 and fall straight through the `$LASTEXITCODE -ne 0` allow — the verdict depended on where the hook process happened to be started, and the pytest harness does not set that cwd, so the coincidence did not even hold under test. **Fourteen new regression cases across eight tests**, every one confirmed against the pre-fix gate first: the five relative spellings (`-C` and the `cd &&` prefix), the relative-linked-worktree case and the fail-closed case FAIL there, while the absolute target, the linked-worktree target, the key-list narrowness controls, the ungoverned-repo control and the not-a-repo fail-open control all keep PASSING — which is what separates a correction from a widening. **Not fixed here, reported instead:** rule 3d (`:625`) carries the byte-similar construct and the filing's assumption that its target is always a linked worktree is false (the primary is itself a registered worktree, and `git worktree remove ` was measured ALLOW), and rule 3c reads only candidate `[0]`, so a `--git-dir=/.git` spelling from an ungoverned cwd is never inspected; both need their own item and their own asymmetry tests. Original filing follows. `git -C config core.hooksPath /dev/null` is DENIED. `git -C ../../.. config core.hooksPath /dev/null` is **ALLOWED** — same command, same target, relative spelling. That disarms `core.hooksPath` for the **shared clone**, i.e. the ledger, claim and secret-leak commit gates for every worktree at once, which is verbatim what rule 3c's own deny text says it exists to prevent. It requires no shell variable and no intent: `../../..` is the natural way to name the repo root from `/.claude/worktrees/`. @@ -5920,67 +4942,6 @@ The second step's arithmetic is measured: `GetFullPath('.git', )` r **Source:** two remediated coordination incidents during the 2026-08-06 backlog campaign, both self-reported by the lane that caused them. Verified against the source before filing: `-Force` genuinely bypasses the holder check, and the release genuinely records nothing. -## 1063. `setup-leak-gate.ps1` picks the checkout from the current directory, so it can arm a worktree the operator did not name - -> ✅ **SHIPPED 2026-08-06 — the root is anchored to the script's own location, and the divergence is under test.** Value **3/10** · Difficulty **1/10** · _quick win_. `$repo` now comes from `Split-Path -Parent (Split-Path -Parent $PSScriptRoot)`, the form `postgres.ps1` and `sqlserver.ps1` in the same directory already use, plus an assert that the derived root actually carries `scripts/security/` — a wrong root should say so at the point of derivation rather than surface later as a confusing scanner failure. Tested by the **divergence**, not the happy path: two temp checkouts that both carry `scripts/security/`, the script invoked by absolute `-File` path while the shell stands in the other one, asserting the token list lands in the checkout holding the script and **not** in the caller's. The pre-fix behaviour was reproduced directly rather than inferred — reverted, the same test reports *"the named checkout was not armed"*. Only the three files the script reaches for are copied into the fixture, never the whole of `scripts/security/`, because a maintainer running the suite has the real token list sitting in that directory. Original filing follows. `scripts/dev/setup-leak-gate.ps1:37` was `$repo = (& git rev-parse --show-toplevel 2>$null)` — no `-C`, no `-Repo` parameter, no `$PSScriptRoot` anchor. Invoked by absolute `-File` path from a different worktree, which is the ordinary shape on a clone with 40-plus of them, it installs the leak-gate token list into **the current directory's** checkout and prints `CONFIGURED` about that one, while the worktree the operator named keeps no token source. Its own directory siblings already do it correctly. - -**Cluster:** Developer tooling / configuration anchoring. **Priority:** P4. **Verdict:** build (trivial). **Severity:** **low, and the low severity is load-bearing** — every failure direction here is loud or fail-closed, which is why this is filed at 3 rather than alongside its siblings. Nothing is silently ungated and no wrong authorisation is granted. - -**Why it is nearly harmless, stated so nobody escalates it on the family resemblance.** The named worktree's pre-commit leak gate keeps failing **closed** — it passes `--require-tokens` deliberately, so a missing token source blocks commits loudly rather than letting content through. And if the destination is not git-ignored, the script deletes the file it just wrote and throws rather than risk committing the token list. The wrong tree genuinely gets a working gate; the right tree keeps refusing. The cost is a confusing `CONFIGURED` and a second run, not an exposure. - -**The fix is one line and the pattern is already in the same directory.** `scripts/dev/postgres.ps1:37` and `scripts/dev/sqlserver.ps1:56` both use: - -```powershell -$repo = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) -``` - -Anchoring on `$PSScriptRoot` binds the script to the checkout it lives in, which is what an absolute `-File` invocation is asking for. `-From` names the token **source**, not the checkout, so it does not already cover this. - -**Same construct as #1060.** `alloc.ps1:51` is the same construct — `git rev-parse --show-toplevel` with no anchor — and produces the same class of wrong answer — there, a misattributed ledger allocation; here, a token list installed into the wrong tree. Fixing them together is reasonable; filing them together was not, because their severities differ by two priority bands and folding this into #1060 would have inflated it. (The filing said *byte-equivalent*. It is not: `alloc.ps1` carries `--path-format=absolute` and this script does not. The defect is identical; the bytes are not, and "byte-equivalent" is the kind of claim a later reader greps for and then trusts.) - -**How it was found, and why that matters more than the defect.** A repo-wide sweep for the cwd-as-identity shape assigned five surfaces and left `scripts/dev` and `scripts/service` in **no** surface at all — seven `.ps1` files in the seam. This was found only because the synthesising agent went outside its brief and swept the unassigned region. A measuring apparatus with a blind spot, hunting mechanisms with blind spots. Worth remembering when the next sweep is designed: **state the unassigned regions, or the result reads as completeness.** - -**Related:** #1060 (the same construct, and the cwd-is-not-the-caller premise recorded in `docs/WORKTREES.md`), #1057, #1059, #1062 (the rest of that cluster), #1000 (the sweep's own coverage gap is that item's shape in a measuring tool rather than a gate). - -**Source:** found 2026-08-05 during the sweep that produced #1062, held unfiled overnight as explicitly marginal, and filed 2026-08-06 on the judgement that a real defect with a known one-line fix is worth a number even at P4 — a low severity is a priority statement, not a filing criterion, and unfiled findings get dropped. - -## 1062. `check` validates the env value file under `--project-root` then reads the values from the current directory - -> ✅ **SHIPPED 2026-08-06 — the root is threaded through and applied the way `serve` applies it.** Value **7/10** · Difficulty **2/10** · _quick win_. `run_checks` gained a `project_root` parameter, threaded to the build check and set as a `[environments].base_dir` **CLI override** — the same mechanism `serve` uses, so `load_settings`' CLI > env > file precedence puts it above a file-set `base_dir`. Left unset the resolution is unchanged and still falls back to the process directory, so `check --config config` is untouched. Two tests, asserted by the DIVERGENCE (the process directory holds its own value file with a different host); the pre-fix behaviour was reproduced directly rather than inferred — values were read from the process directory while the root was the one validated. Original filing follows. `messagefoundry check --project-root R` anchors `--config` under `R` and **hard-fails** if `R//.toml` is absent — then drops `R`. `run_checks` takes no project root, so the build check re-derives the value anchor from `Path.cwd()`. The gate therefore **verifies the file under the root you supplied and reads the values from wherever your shell happens to be.** `serve` does not have this defect, in the same file, by one line. - -**Cluster:** Configuration anchoring / gate integrity. **Priority:** P2. **Verdict:** build (small). **Severity:** would mis-decide a **required, blocking** check on a deploying site. Nothing is deployed (§0), so this is what a deploying site would hit on first use, not something happening today. It is also the only finding in this cluster on **product code** rather than developer tooling. - -**Verified by reading the chain end to end, 2026-08-06.** Not inferred from a grep: - -``` -__main__.py:832 root = resolve_project_root(args.project_root, cwd=cwd) -__main__.py:833-4 config_dir / service_config anchored under root -__main__.py:848-52 EXPLICIT root + --env -> hard-fail if //.toml is absent -__main__.py:853 return config_dir, service_config <-- root is DROPPED here -__main__.py:4263+ run_checks(config_dir, ..., service_config=...) <-- no root parameter exists -checks.py:1304 resolve_values_base_dir(settings.environments.base_dir, cwd=Path.cwd()) -environments.py:79 `if not base_dir: return cwd` <-- and base_dir is unset by default -``` - -**`serve` gets it right one screen away.** `__main__.py:1086` does `cli.setdefault("environments", {})["base_dir"] = args.project_root` *before* `load_settings`, and the comment at `:1095` records that this is exactly why. `check` never sets it, so `settings.environments.base_dir` stays empty and `resolve_values_base_dir` falls back to the process directory. - -**The comment above the defect claims the parity that is missing**, which is the sharpest evidence it is an oversight rather than a decision. `checks.py:1300-1302` reads: *"Resolve env() against the active environment **the same way serve does**, so a hop's host/scheme (an env()-supplied value) is built exactly as at runtime rather than left as an unresolved reference."* Serve's way **is** the `base_dir` assignment. The comment states the goal and the code omits the step that achieves it. - -**Consequence, in the conditional.** `build-check` is a required blocking check whose stated job is the ADR 0092 posture-keyed insecure-hop refusal, and the hosts and schemes it judges are `env()`-supplied. Run as `check --project-root R --env prod` from a directory `W`: - -- **If `W` holds its own `environments/prod.toml`** — the refusal is decided against **W's** values while the operator was told `R` was validated. A cleartext egress hop that `R` forbids could pass with exit 0. No diagnostic names which directory was read: `_emit_anchor_diagnostics`, including the AC-4 "cwd differs from root" warning, is **serve-only**. -- **If `W` holds no `environments/`** — a spurious blocking failure reporting a missing value file, which is loud but points at the wrong directory. - -**Reachability, stated honestly.** Nothing in this repo's CI, hooks or scripts passes `--project-root` to `check`; the shape is the documented consumer / config-repo invocation, which ADR 0050 AC-6 ratifies. So it is **supported but not exercised here** — which is also why no test caught it. Do not write this up as "unreachable": the invocation is the one a config repo is told to use. - -**The fix is the line `serve` already has.** Either give `run_checks` an explicit project-root parameter and thread it to the anchor, or have `check` set `[environments].base_dir` from `--project-root` before settings load, exactly as `serve` does at `:1086`. The second is smaller and makes the two paths converge rather than diverge further; the first is more explicit about what `run_checks` depends on. Either way `_check_build` must stop consulting `Path.cwd()` when a root was supplied. - -**Test it by the divergence, not by the happy path.** The case that matters is `--project-root R` run from a `W` that holds a *different* `environments/.toml`, asserting the value actually used comes from `R`. A test run from inside `R` passes with the bug in — the same shape as the Windows-versus-Linux masking that hid the rule 3d defect, and per #1000 a control needs the case that can distinguish. - -**Related:** #1057, #1059, #1060 (the cwd-is-not-the-caller cluster — this is its fourth instance and the only one on product code), #1000 (a required check green because it read the wrong directory), ADR 0050 AC-6, ADR 0092. - -**Source:** surfaced 2026-08-05 by a repo-wide sweep for the cwd-as-identity shape, reported as one of five candidates and held as **relayed, not confirmed** until the chain was read end to end on 2026-08-06. Filed only after that verification: the sweep's own severity ranking put it first, and a subagent's severity claim is not evidence. - ## 1064. Rule 3d assumes its target is a linked worktree, but the primary is a registered worktree too, so removing it by a relative path is allowed > 🔢 **Filed 2026-08-06 — not started, and the RELAYED half is now CONFIRMED (2026-08-06).** The filing below says the `git worktree remove ` ALLOW was relayed rather than measured, and asks that it be confirmed before the severity is acted on. It has been, from the other end and by accident: a non-vacuity control written for the pytest harness's cwd repair (`test_rule_3d_STILL_depends_on_where_the_hook_process_stands`) measures the SAME payload twice, moving only where the hook PROCESS stands. `git worktree remove ../Primary-wt` DENIES when the hook runs where the session is — production, and what the repaired harness now does — and ALLOWS when the hook process stands anywhere else. That is this item's mechanism seen directly: the relative victim is resolved against the wrong base. ⚠️ ONE TRAP, MEASURED, because it made the dependence read as ABSENT on the first attempt: if the foreign directory is a SIBLING of the victim, the same relative token resolves to the same place from both, every verdict agrees BY LUCK, and the instrument reports invariance. The control's fixture is nested for that reason. **Nothing about rule 3d was changed** — it shares a resolver with rules 3 and 3b, so a change there must be measured against THEIR controls rather than credited to the rule-3c work. Value **6/10** · Difficulty **2/10** · _do it_. Rule 3d resolves its victim's common dir with `Get-ComparablePath $victimCommon $victimRaw` (`worktree_gate.ps1:625`) — the **same construct, against the same kind of base**, that #1061 has just been fixed for in rule 3c. `$victimRaw` is the path token as written, so a relative target makes `GetFullPath` throw on a non-fully-qualified base, the catch returns `""`, no governed root matches, and the rule falls through to ALLOW. @@ -6189,87 +5150,6 @@ git -c 'alias.ci=commit --no-verify' ci -m x **Related:** #1069 (the quote mask these shapes sit outside), #1070 (the environment channel, a different mechanism), #1065, #1061, #1000. **Source:** the backtick and heredoc shapes from round 3's design pass, the self-referential-token class from its independent design review, the `cd` shapes from round 2's own "does not establish" list. -## 1073. Mine the free ASCQM 1.1 weakness catalogue against the existing gates; decline ISO 5055 as a measure - -> ✅ **SHIPPED 2026-08-07 — the pass ran over all 74 live elements; the measure stays declined.** Value **4/10** · Difficulty **3/10**. Findings filed as #1089, #1090, #1091, #1092 and the #1093 inventory. The decline marker now sits in [`../CLAUDE.md`](../CLAUDE.md) §12, which is the part that outlives this item — a decline recorded only here would vanish when this item archives, exactly as #26 and #27 would have. Original filing follows. ISO/IEC 5055:2021 defines four quality measures as **counts** of CWE-keyed severe weaknesses. The **measure** is declined for the reasons below and should not be re-litigated. The **catalogue** behind it is free, curated by a standards body, and contains a slice worth one bounded pass: the system-level weaknesses that a unit-level linter structurally cannot see. - -**THE COUNTS ARE RESOLVED, and the conflict was a UNITS problem nobody had named.** CISQ's 74 / 74 / 29 / 15 counts **CWEs**, including contributing child CWEs. ASCQM's 22 / 29 / 15 / 20 counts **elements**, and one element carries several CWEs — which is why the element count is roughly a third of the CWE count. Measured from the spec: **84 elements, 74 live, 10 marked Dropped by the standard itself.** Security 22, Performance Efficiency 15 and Maintainability 20 reconcile **exactly**; Reliability came to 27 against 29 expected, and `ASCRM-RLB-13` carries no CWE mapping — both are **known shortfalls, not resolved**. **Performance Efficiency = 15 is now CONFIRMED** from the spec and its unverified mark is lifted. **The "139 total" stays UNCONFIRMED**: 152 distinct CWE references appear across the 261 pages, but that is a mention count over the whole document including front matter, so it neither confirms nor refutes 139. That mark stays, and it is doing its job. - -**THE FIRST RUN SILENTLY EXAMINED 62 OF 74 ELEMENTS, AND THE RESULT LOOKED COMPLETE.** One of six triage batches died on a connection error. The surviving five returned a confident report with a headline gap count, and **nothing in it indicated that a sixth of the catalogue had never been read.** The 12 unexamined elements spanned all four measures and included three Security elements (CWE-99, CWE-456, CWE-789) — and CWE-456 became a filed finding (#1093) once actually judged, so the omission was **not** harmless. It was caught by arithmetic (62 + 12 = 74), not by any signal the run produced. Recorded because it is this repo's own [`Code_Quality_Standards.md`](Code_Quality_Standards.md) §4.0 failure mode reproduced **inside the tool built to hunt for it**: a process that reports a conclusion without recording what it measured is indistinguishable from one that measured everything. **Any future catalogue pass must assert its own coverage before its findings are read.** - -**Cluster:** Code quality / standards coverage. **Priority:** P3. **Verdict:** build (small) for the pass; **decline** for the measure. **Severity:** no product effect and no security effect — this is a coverage question about the gates, not a defect in them. - -**The decline, stated first so it stays decided. Three reasons, any one sufficient:** - -1. **No conformant measure is producible for this codebase.** There is no free or open-source ISO 5055-conformant Python analyser. The conformant ecosystem is C/C++/Java/C#/COBOL-weighted: Perforce names Helix QAC and Klocwork, neither of which analyses Python; Kiuwan analyses Python commercially, and conformance claims are language-scoped. A measure nobody here can compute cannot be a gate, a scorecard row, or a claim. -2. **No procurement pull.** 5055 exists to be **cited in a contract** — an outsourcer and a buyer writing "the delivered system shall score X" into a statement of work. MEFOR is open source distributed on PyPI; there is no contract counterparty for that clause. Health-system buyers ask for HIPAA mapping, SOC 2, HITRUST and ASVS. -3. **It collides with this project's own ratified rule.** [`Code_Quality_Standards.md`](Code_Quality_Standards.md) §4.1 forbids certifying quality on a single number, on adversarially-verified evidence. **Be fair to 5055 on this point:** counting *specific named severe weaknesses* is a materially better construct than the SonarQube severity buckets §2 refuted, so the collision is with the "our ASCQM Security score is N" framing, **not** with the weakness list itself. That distinction is the whole reason the catalogue survives the decline. - -**What is worth taking, and it costs nothing.** The OMG **ASCQM 1.1** specification (formal, July 2022) — which is the technical content ISO/IEC 5055:2021 carries — is downloadable from `omg.org/spec/ASCQM/` as a **non-member PDF plus a machine-readable XMI**. The ISO document does not need to be bought to read the weakness list. - -**Counts, with the unverified ones marked.** Confirmed from CISQ: **Security 74** (36 parent + 38 child), **Reliability 74** (35 + 39), **Maintainability 29**. **Performance Efficiency is widely quoted as 15, and the widely-quoted "139 total" likewise, and NEITHER was confirmed against a primary source** — do not restate either without checking the ASCQM PDF directly. They are recorded here as unverified precisely so the next reader does not launder them into a doc. - -**The work: one bounded pass, two questions per weakness.** *Could this occur in this codebase?* and *does any current check see it?* A no/no pair becomes a backlog item or a semgrep rule, and nothing else is produced. The high-yield slice is the **system-level** entries — weaknesses visible only across component boundaries and data flows. That is a real blind spot for a three-stage persisted pipeline with three store backends, and it is the one thing the catalogue offers that ruff, mypy, bandit, semgrep and CodeQL do not already cover between them. - -**Expect a high not-applicable rate, and do not read it as a result.** The Reliability and Security lists lean heavily on memory management, pointer arithmetic and buffer bounds. This is the same shape already measured against ASVS V10, where 25 of 27 cells were carried as not-applicable. A large n/a count is a fact about the language, not about the code. - -**Scope fence, and it is the load-bearing part of this item.** The output is items or rules. **Not** a fifth standards document, **not** a scorecard, **not** a gate, **not** a status row anywhere. The project already carries four standards documents, the ASVS scorecard, the HIPAA/800-66 mapping and the CISO register; each additional framework is another surface on which a claim can go stale, and this repo has already been bitten by exactly that — [`Code_Quality_Standards.md`](Code_Quality_Standards.md) §4.0 exists because three gates were green while measuring nothing. - -**Difficulty 3 is the judgment, not the reading.** The pass is mechanical; "does any current check see it" is the question that goes wrong. Answering it from a gate's *name* rather than from its *measured output and scope* is the §4.0 failure mode reproduced by hand. Every "covered" answer must name the check and state its scope — `jscpd` sees `messagefoundry/` only, the mutation gate sees one module, `testpaths` excludes the webconsole package (#1027). A coverage claim that does not name its instrument is not a coverage claim. - -**Related:** [`Code_Quality_Standards.md`](Code_Quality_Standards.md) §4.0 (gates that measure nothing) and §4.1 (the anti-metric rule), #1006 (a mutation that matches is not a mutation that bites — the same "the check ran" versus "the check bites" distinction), #1027 (a green that is not evidence about what it appears to cover), #1074 and #1075 (the SSDF half of the same question). - -**Source:** owner question 2026-08-06 — "is ISO/IEC 5055:2021 / OMG ASCQM 1.1 valuable, should we be applying it". Filed as the answer's actionable residue. The tool-support and procurement findings are from a research pass that day; the counts are as marked. - -## 1074. The SDS attestation posture does not record that the CISA self-attestation exempts freely-available OSS - -> ✅ **SHIPPED 2026-08-07 — the documentation is the whole deliverable.** Value **4/10** · Difficulty **1/10** · _quick win_. Two paragraphs added to [`Secure_Development_Standards.md`](Secure_Development_Standards.md) **§9** (see the citation correction below), plus the 800-218A guard placed in the AI companion's `Aligns to` row rather than its body — that row is where a reader would go to add the wrong anchor, so that is where the note has to be. Original filing follows. §9 states the software is *"self-attested as NIST SSDF-aligned"* and never said what that attestation is, and is not, answerable to. The CISA Secure Software Development Attestation Form explicitly **exempts software that is freely obtained and publicly available**. One missing sentence, and its absence invited an error in **either** direction. - -**CITATION CORRECTION, and it was wrong as filed.** This item said the attestation posture lives in **§6.3**. It does not: §6.3 is *OWASP ASVS 5.0 Level 3 — scope*, and the attestation posture is in **§9 Evidence and attestation**. The error came from matching the phrase without opening the section around it. Recorded rather than silently repaired because a wrong section pointer in a filed item is the same defect class the item itself is about — a claim nobody has checked and a claim nobody has noticed is wrong look identical until someone follows it. - -**Cluster:** Standards record / attestation honesty. **Priority:** P3. **Verdict:** build (small). **Severity:** no product effect and no security effect. The defect is in the record: a reader cannot tell from §6.3 whether the SSDF alignment discharges an obligation or volunteers evidence, and those imply different things about what may be claimed to a buyer. - -**The fact to record.** The CISA Secure Software Development Attestation Form (finalised 2024-03-11) does not require attestations for software that is freely obtained and publicly available, nor for open-source software obtained directly by a federal agency, nor for third-party open-source components incorporated into an end product. - -**Two consequences, pulling in opposite directions — which is exactly why it is one sentence and not a paragraph:** - -- MEFOR's SSDF alignment is **voluntary buyer evidence, never a regulatory obligation**. Nothing about it is owed to anyone today, and a doc that implies otherwise overstates the project's standing. -- **The exemption stops applying to a paid or hosted offering.** A commercial tier changes the analysis, and writing the condition down now is what makes that visible later instead of assumed. This is the more valuable half: the trap is a future reader inheriting an exemption whose precondition has quietly lapsed. - -**Absence is what invites the error, not any wrong sentence that is there today.** With nothing written, a later reader can equally well claim compliance value the project does not have, or assume an obligation that does not exist. Both are instances of the class [`../CLAUDE.md`](../CLAUDE.md) §11 names — a compensating control, or a claim, resting on a false premise. - -**Second half of the same edit: do not anchor the AI companion to SP 800-218A.** 800-218A is the **Generative AI profile** — practices for organisations *producing* AI models and dual-use foundation models. It is **not** about building software *with* an AI assistant, which is what [`Secure_AI_Development_Standards.md`](Secure_AI_Development_Standards.md) governs. **Verified 2026-08-06: nothing in `docs/` cites it.** Keep it that way and record *why*, so the next reader who notices an SSDF companion with "AI" in the title does not wire in a plausible-looking but wrong anchor. That companion currently has no NIST anchor, and it does not need a wrong one. - -**Difficulty 1.** Two sentences in SDS §6.3, one line in the AI companion. No code, no gate, no scorecard change. - -**Related:** #1075 (the other SSDF record item — that one is trigger-gated, this one is actionable now), #1053 (a document calling built things "planned" — the same class of defect, the record disagreeing with the facts), [`../CLAUDE.md`](../CLAUDE.md) §11. - -**Source:** owner question 2026-08-06 — "what about NIST SP 800-218 v1.1 (SSDF)". The answer was that SSDF is already adopted throughout the SDS; this is one of the two deltas that survived checking. - -## 1075. Re-map SDS section 4 when NIST SP 800-218r1 (SSDF 1.2) goes final - -> ✅ **CLOSED 2026-08-07 — NOT by doing the re-map, which remains correctly undone.** Value **3/10** · Difficulty **4/10**. Closed on the owner's reading, which was right: the SDS maps **SP 800-218 v1.1, and v1.1 is the current final version**, so this item described zero present work and zero present defect. A watch item for an event with no announced date is backlog noise. **Its one load-bearing sentence was not discarded — it was re-sited**, into [`Secure_Development_Standards.md`](Secure_Development_Standards.md) §9 alongside #1074, where the reader who would re-map against the draft is actually looking. A guard in the document beats a guard in the ledger. - -**DO NOT read this as "the SSDF 1.2 re-map is done."** It is not started and must not be started: SP 800-218r1 is still an Initial Public Draft (published 2025-12-17, comments closed 2026-01-30, no announced finalisation date). The trigger, the per-ID re-resolution rule, and the PW.7-deviation caveat now live in SDS §9. **If r1 goes Final, that is a new item** — do not reopen this one, because its number is closed and a reopened closed item is invisible to anyone reading the ledger for open work. - -**Cluster:** Standards record. **Priority:** P3. **Verdict:** build, **when triggered**. **Severity:** none today — the SDS is correct as it stands. - -**Status, verified against `csrc.nist.gov` on 2026-08-06.** SP 800-218r1 (SSDF Version 1.2) is an **Initial Public Draft**, released 2025-12-17; the comment period closed 2026-01-30; **no finalisation date has been announced**. SP 800-218 v1.1 (February 2022) remains the current final version. - -**Why the record is right as it stands.** [`Secure_Development_Standards.md`](Secure_Development_Standards.md) pins *"NIST SP 800-218 (SSDF)"* v1.1 in its `Aligns to` line, and §4 is organised by its four practice groups (PO / PS / PW / RV) with practice IDs cited natively — PS.2, PO.4, PW.1–PW.2, PW.7, PW.8. Every one of those resolves correctly against the current final standard. Nothing is stale; the item is a **watch**, not a repair. - -**The trigger.** SP 800-218r1 reaching **Final** status on `csrc.nist.gov`. Not a new draft, not a second comment period. - -**The blast radius, so the cost is visible before anyone starts.** Measured 2026-08-06: **143 SSDF references across 11 files** — the SDS itself, [`Secure_AI_Development_Standards.md`](Secure_AI_Development_Standards.md), [`Secure_Build_Standards.md`](Secure_Build_Standards.md), [`Secure_Build_Scorecard_MEFOR.md`](Secure_Build_Scorecard_MEFOR.md) (which *grades* under the practice groups, including the documented single-maintainer deviation for PW.7), [`Code_Quality_Standards.md`](Code_Quality_Standards.md) (which maps its signals to PW.7 / PW.8), plus scattered citations in `PHI.md`, `ARCHITECTURE.md`, ADR 0109, the master test plan, `.github/SECURITY.md` and the CHANGELOG. **That is the size of the change, not a to-do list** — several of those are prose mentions needing no edit at all, and treating the count as a checklist is how a re-map becomes a week. - -**Difficulty 4 is the ID churn, not the reading.** SSDF 1.2 renumbers and adds practices and tasks, so **a mechanical find-and-replace is exactly the wrong instrument**: a citation that still resolves to a real practice ID but a *different* practice is the failure that looks like success, and nothing in CI can see it. Every cited ID must be re-resolved against the new text by hand, and the single-maintainer deviation in the Secure Build scorecard has to be re-justified against whatever 1.2 says about review, not carried across on the assumption that PW.7 still means what it meant. - -**Do not act early.** Do not re-map against the draft, and do not track it incrementally as the draft changes — a draft that moves twice costs the re-map twice and can still land somewhere else. - -**Related:** #1074 (the same document's attestation posture — actionable now, unlike this), #1073 (the ISO 5055 half of the same question). - -**Source:** owner question 2026-08-06 — "what about NIST SP 800-218 v1.1 (SSDF)". Draft status re-verified directly against the CSRC publication page the same day rather than taken from a secondary summary. - ## 1077. `announce-session.ps1` skips reachable peers on `isRunning: false`, which does not mean dead > 🔢 **Filed 2026-08-06 — not started.** Value **6/10** · Difficulty **2/10** · _quick win_. The announce hook instructs every session *"No exact row, or isRunning is false -> SKIP that peer"*, then has the model record the outcome under the token `NOT_RUNNING`. Measured 2026-08-06: a peer listed `isRunning: false` was **delivered to** and answered within one turn, while `isRunning: true` **queued** behind the in-flight turn. The field reads **backwards** as a reachability signal, so the rule drops exactly the peers most able to answer. @@ -6341,35 +5221,6 @@ Both readings reach the same operational conclusion, which is the whole point of **Source:** observed 2026-08-06 while arming a fresh worktree during #1063's fix. Held unfiled as marginal, and filed on the owner's instruction. -## 1081. released-line audit: detect an advisory against the latest release's pinned runtime - -> ✅ **Shipped 2026-08-07.** `released-line-audit` in `.github/workflows/security.yml` audits the **latest release tag's** `docker/locks/requirements-core.lock` on the existing daily cron, plus `workflow_dispatch` with a tag override. Advisory by placement (schedule/dispatch-only, so it can never report on a PR) but **not** `continue-on-error`: it goes red on a finding. `nightly-notice.yml` was extended to watch `Security` so a red scheduled run reports somewhere. - -**Cluster:** Supply chain / CI. **Priority:** P3. **Verdict:** built, reduced. **Severity:** low — there are zero deployments, so this closes a window before anyone is in it. - -**The gap, stated correctly — and it is NOT the one first claimed.** The original framing was *"nothing re-evaluates a published VEX against advisories disclosed after its tag"*, offered with CVE-2026-69247 as evidence. That framing is **wrong and was retracted**. The advisory was caught the day it published, by an existing required gate: commit `ac87246f` records *"pip-audit (a required gate) flagged cryptography 49.0.0 for CVE-2026-69247"*. Nor was `main` ahead of the tag — `git show ac87246f^:docker/locks/requirements-core.lock` and `git show v0.3.2:docker/locks/requirements-core.lock` both read `cryptography==49.0.0`. Detection was never missing. - -What was unwatched is the **release-lag window**: the interval between a fix landing on `main` and a release carrying it. `pip-audit` reads the checked-out tree, so on the daily cron it answers *"is what we would ship next current"*. That is a different question from *"does the version we already shipped carry a known advisory"*, and the two answers diverge for exactly the length of that window. - -**Residual scope, so nobody over-reads a red run.** This audits the **core runtime closure only** (`requirements-core.lock`), which is what the shipped CycloneDX SBOM inventories. A wheel adopter resolves against `pyproject.toml`'s floors (`cryptography>=48.0.1`), and no container image is published at release, so a finding here is a statement about **the published SBOM's inventory**, not about every install. Extras and the CI toolchain stay covered against `main` by the `pip-audit` job. - -**Pre-merge self-tests (all four passed; the instrument was proven able to see the class).** Against `v0.3.2`'s lock `pip-audit` exits 1 naming `PYSEC-2026-3552` on `cryptography 49.0.0`; against `origin/main`'s lock it exits 0 — so it distinguishes the two states rather than only ever reddening. An empty lock reads 0 pinned requirements against a floor of 25, hitting the fail-closed path. The tag selector returns exactly `v0.3.2` and excludes `webconsole-v0.2.15`. The positive control is durable: the vulnerable lock lives in git history, so `workflow_dispatch` with `released_line_audit_tag=v0.3.2` re-arms it forever. - -**Deliberately NOT built, with reasons — this is the part worth not re-litigating.** - -1. **Scanning the published `messagefoundry-sbom.cdx.json` asset with trivy.** The SBOM is generated by installing the core lock into a clean venv, so the lock **is** the population. Scanning the asset answers the same question plus *"did the generator inventory it correctly"* — a real but different defect — at the cost of a second pinned scanner, a second vulnerability database, and divergence risk against the operator-facing command in `docs/SUPPLY-CHAIN.md`. -2. **Applying any VEX to this gate.** Refuted during design and the reason is subtle: `security/vex/README.md`'s own worked example names the product with **no version qualifier**, so a `fixed` or `not_affected` statement written on `main` would suppress the finding against the already-shipped release. The gate would turn green the moment the assessment was written, before any release carried the fix. `--ignore-vuln ` is the escape hatch — explicit, per-advisory, greppable. -3. **A merge-blocking VEX linter.** As specified it would reject `security/vex/README.md`'s own example and mandate a non-OpenVEX field in a document shipped to hospital scanners. There are zero statements today, so there is nothing to lint. -4. **A release-time VEX version-bump gate.** Real (nothing enforces the documented bump), but with no statements at `version: 1` across every release so far there is no violation and no way to exercise the failing shape. -5. **An in-job issue filer.** Two notifiers for one failure. Extending `nightly-notice.yml` covers every scheduled `Security` job, not only this one. -6. **A `release: published` trigger.** `release.yml` creates the release and uploads assets in one call, so a `published`-triggered run can race the upload. The daily cron bounds detection at ~24h. - -**Also deferred:** the `docs/SUPPLY-CHAIN.md` half of this change — a sentence scoping *"continuously audited by pip-audit"* to `main`'s lockfiles, and the `releases/latest/download/...` permanent fetch URLs. Held back only because PR #264 edits the same file and stacking the two would risk a conflict; land it once #264 merges. - -**Related:** #1079 (the same workflow's header denying a trigger its `on:` block declares), ADR 0149 (the SBOM/VEX program this sits beside — unchanged, and it needs no amendment). - -**Source:** found 2026-08-06 while auditing the shipped v0.3.2 release assets. The original design was refuted 3 of 3 by adversarial review and rebuilt at roughly one tenth the size; the retained design record is in the vault. - ## 1087. `new.ps1` sets a worktree branch's upstream to the BASE, so every instrument keyed on `@{u}` answers a different question than the one asked > 🔢 **Filed 2026-08-07 — not started.** Value **7/10** · Difficulty **1/10** · _quick win_. `new.ps1:133` runs `git worktree add -b ` with `-Base origin/main`. Git's default `branch.autoSetupMerge` then sets the new branch's upstream to the remote-tracking base, so **`@{u}` resolves to `origin/main`, not to the branch's own remote ref** — measured on a live worktree. The visible symptom is trivial. The consequence is not: **`@{u}..HEAD` reports a branch's own commits as "unpushed" forever, including immediately after a successful push.** @@ -6492,40 +5343,6 @@ What was unwatched is the **release-lag window**: the interval between a fix lan **Source:** BACKLOG #1073's ASCQM 1.1 catalogue pass over all 74 live elements, with an adversarial refutation stage on every non-not-applicable verdict. -## 1094. CLAUDE.md §12 decline markers cite the live backlog file for items that have archived - -> ✅ **Closed 2026-08-07 — already satisfied when filed; no work was performed under this number.** The repoint this item asks for merged as `befe997e` (PR #271) **one commit before this item itself landed** (`7ecff8ae`, PR #272). Re-verified on `origin/main` after both: §12 now reads *"BACKLOG #26 — closed, so it lives in [`docs/archive/backlog/BACKLOG-CLOSED.md`](archive/backlog/BACKLOG-CLOSED.md), not in the live ledger"*, and the same for `#27`. The finding below was true when measured and stale by the time it was recorded — a filing race, not a wrong observation. Original scoring, for the record: Value **4/10** · Difficulty **1/10** · _quick win_. -> -> ⚠️ **The two markers named here were the whole scope, and they are only two instances of a much larger class.** A repo-wide sweep the same day found **at least 90** further path-bearing citations naming `docs/BACKLOG.md` for an item that lives in the archive, plus broken relative hrefs and stale line anchors. That breadth is **#1095**, which also carries the detectability argument below at its true scale. Closing this number does not close that. - -§12's **Don't** list is where a decline is lifted so it **outlives the backlog item that recorded it**. Two of its markers cited [`docs/BACKLOG.md`](BACKLOG.md) `#26` and `#27` — and retiring an item **moves it verbatim** into [`archive/backlog/BACKLOG-CLOSED.md`](archive/backlog/BACKLOG-CLOSED.md). Measured on `origin/main` 2026-08-07: `## 26.` and `## 27.` were **absent** from `docs/BACKLOG.md` and **present** in the archive. Both pointers were dead until `befe997e` repointed them. - -**Cluster:** Documentation record / instrument accuracy. **Priority:** P3. **Verdict:** build (trivial) — superseded by the fix having already landed. **Severity:** no product effect and no security effect. The decline text itself was intact and still binding throughout; only the route back to its reasoning was broken. - -**Nothing in this repository can catch this class today, and that is the argument for whatever check is proposed.** The markdown link resolves perfectly — it points at `docs/BACKLOG.md`, which exists — so a link checker cannot fire. The part that goes stale is the **human-readable number beside the link**, which no tool reads. That is why two instances sat unnoticed rather than being caught by the gates this repo already runs. A check that only validates link targets will report this file clean forever. - -**The repo already has the correct form, at scale.** `docs/BACKLOG.md` carries **44** citations shaped `[#52](archive/backlog/BACKLOG-CLOSED.md#52-corepoint-capability-parity-gaps--prioritized-roadmap-input-2026-06-27)`, and [`AOAG-DEPLOYMENT.md`](AOAG-DEPLOYMENT.md) does the same for `#100`/`#101`. So this is a §12 omission, not a missing convention. - -**Fix shape.** Repoint the `#26` and `#27` markers at the archive. **Do not hand-write the fragment** — a pointer with a wrong fragment is worse than one with none, because it looks precise and lands nowhere. Derive it from the item's own heading text under GitHub's slug rule, or cite the file without a fragment. A marker that must outlive its item is best served naming **both** locations (live now, archive after), which is what [`../CLAUDE.md`](../CLAUDE.md) §12's ISO 5055 marker was corrected to do. - -> **CORRECTION 2026-08-10 (BACKLOG #1099) — the sentence above named tooling that does not exist.** -> It originally read *"the archival pass generates the anchor … Either derive it from the generator or -> cite the file without a fragment."* **There is no archival tooling in this repository**: closing an -> item is a manual move of its text from this file into -> [`archive/backlog/BACKLOG-CLOSED.md`](archive/backlog/BACKLOG-CLOSED.md), and the fragment is -> GitHub's heading slug, which nothing here generates. -> [`tests/test_link_resolution.py`](../tests/test_link_resolution.py) states the same thing from the -> other side — *"The move is manual — no script performs it — so there is nothing to fix upstream"* — -> and draws the conclusion this sentence pointed away from: a guard at the moment the item lands is -> the only thing that can catch the rot, because there is no generator to fix. Corrected rather than -> rewritten silently, since the block around it is closed record. - -**The near-miss is the reason this is worth a number.** The 5055 decline marker filed under #1073 cited only the live file, and would have rotted identically the moment #1073 archived — caught in review before merge. The session that wrote it had **already noticed** the #26/#27 staleness earlier that day and judged it not worth chasing, then reproduced it in a marker whose entire purpose is to outlive its item. A rot consciously declined is one you have stopped seeing well enough to avoid repeating. - -**Related:** #1073 (the decline whose marker nearly repeated this), #1000 (a control whose green is not evidence about what it appears to cover), #1087 and #1063 (the same cluster — an instrument answering a narrower question than the one asked), [`../CLAUDE.md`](../CLAUDE.md) §11 (state a load-bearing fact once and link to it — which is what makes the link's durability load-bearing). - -**Source:** found 2026-08-07 while verifying #1073's §12 marker against `origin/main` for HANDOFF-1073 item B5. The `#26`/`#27` absence was measured in both files rather than inferred, and the 44-occurrence convention count was re-run without a head limit after a first reading of "~18" turned out to be an artifact of the truncated output. - ## 1095. Backlog citations across the repo name the live ledger for items that have archived > 🔢 **Filed 2026-08-07 — not started.** Value **5/10** · Difficulty **4/10**. Retiring an item moves it verbatim from [`BACKLOG.md`](BACKLOG.md) into [`archive/backlog/BACKLOG-CLOSED.md`](archive/backlog/BACKLOG-CLOSED.md), and every citation that named the live file keeps pointing at a file the item is no longer in. **#1094** fixed two such markers in `../CLAUDE.md` §12; this is the same defect at repo scale. Measured on `origin/main` 2026-08-07 with `parse_items` for item locations: of **129** path-bearing `BACKLOG.md` citations, **at least 69 distinct sites across at least 35 files** name the live ledger for an item that lives in the archive. @@ -6747,40 +5564,6 @@ field whose meaning depends on which row you are reading. **Source:** observed 2026-08-07 during the #1095 work and deferred at the time only for ledger contention; recorded in the #1095 handoff note rather than lost. -## 1099. BACKLOG #1094 describes an archival pass that generates anchors; no archival tooling exists - -> ✅ **Closed 2026-08-10 — the sentence is corrected in place, and the absence was re-confirmed by search rather than inherited.** Value **4/10** · Difficulty **1/10**. #1094's *"the archival pass generates the anchor … derive it from the generator"* now carries a dated `CORRECTION` blockquote naming the manual move and GitHub's heading slug, left as a marked correction rather than a silent rewrite because the block is closed record. Filed 2026-08-07. - -**Cluster:** Documentation record / instrument accuracy. **Priority:** P3. **Verdict:** build (a -prose correction). **Severity:** no product effect. - -**Why this is not pedantry.** The sentence points maintenance at the wrong place: it implies the fix -for anchor rot belongs in a tool, when the only thing that can catch it is a gate at the moment the -item lands - which is exactly the reasoning [`tests/test_link_resolution.py`](../tests/test_link_resolution.py) -records. A future reader looking for the generator to fix will not find one. - -**The absence, re-measured 2026-08-10 rather than quoted.** `git ls-files | grep -iE archiv` returns -**seven** paths and every one is a document — `docs/archive/backlog/BACKLOG-CLOSED.md` and six under -`docs/archive/throughput/`. No script, no CI job, no hook. The independent corroboration is the gate's -own docstring: *"The move is manual — no script performs it — so there is nothing to fix upstream."* - -**TWO CORRECTIONS TO THIS ITEM'S OWN TEXT, both the class it was filed about.** - -- It said the sentence *"now sits in the archive"*. It did not — #1094 was closed-in-live, still in - [`BACKLOG.md`](BACKLOG.md), and the correction was therefore applied there. It travels into the - archive with #1094's block in the same change. -- It cited `tests/test_archive_link_resolution.py`, **which is on no merged ref.** PR #281 squash-merged - as `6cb34f5f` and the file landed as `tests/test_link_resolution.py`; the pre-squash name survives - only on the stale local branch `refs/heads/pr281`. An item about a citation naming a thing that does - not exist cited a thing that does not exist. Found by searching every ref, not by trusting the string - — the same instrument the file's own Ledger erratum prescribes. The identical stale name in #1095's - block was corrected with it. - -**Related:** #1094, #1095 (the repo-scale instance of the same class), #1000. - -**Source:** found 2026-08-07 while resolving the #1095 anchor classes; the absence of archival -tooling was confirmed by looking for it, not assumed. - ## 1100. The master test plan asserts document contradictions that were resolved before it was written > 🔢 **Filed 2026-08-07 - not started.** Value **6/10** · Difficulty **3/10**. Nine sites in the master test @@ -6825,112 +5608,6 @@ Every one of the nine was proposed as a clean repoint by a first-pass reader and reader instructed to refute by default, who checked each citing claim against the code rather than against the anchor. -## 1101. the connscale empty_claims_monotonic SLO reports runner contention as an engine defect - -> ✅ **SHIPPED 2026-08-08 - the SLO now reads empty claims PER MESSAGE, and the latent `claim_mode` grouping defect went with it.** The asserted metric is `empty_claims_per_msg`, computed as the ratio of two rates taken over the SAME first-to-last in-hold samples, so the span cancels algebraically and the quantity is exactly `Δempty_claims / Δread` -- there is no wall clock left for runner contention or a mid-hold reload stall to move. `_monotonic_slo` now groups by `(sweep_mode, claim_mode)` rather than `sweep_mode` alone, so a profile combining `per_lane` and `pooled` can no longer chain-compare across claim modes. The per-second numbers are retained in the report as the operator-facing figures; they are simply no longer what gates a merge. **Verified against the failure mode, not just for green:** eight tests pin the invariance property AND the still-detects-a-real-regression property together, and both were shown to go RED under mutation - reverting the grouping fails 3, restoring the per-second metric fails 2. A metric that never fires would have passed a stability test alone, which is why the two are pinned as a pair. **Not done, and deliberately:** gating `reload_seconds` directly was raised below as a conditional ("if that cost is worth gating") and is a separate judgement, not part of this fix. Original filing follows. Value **4/10** · Difficulty **2/10**. -> `tests/test_connscale_smoke.py:170` asserts `empty_claims_monotonic`: the N=24 empty-claim **rate per -> second** must be at least 0.75x the N=12 rate. The metric has wall-clock in its denominator and a -> deliberately un-gated O(N) probe in its numerator's way, so **CPU contention alone flips it red with -> no engine change**. It reds PRs that touch nothing it measures. - -**Cluster:** Developer Experience & CI. **Priority:** P2. **Verdict:** build. **Severity:** no product -effect, no PHI effect. The cost is queue throughput: a spurious red on a shared runner costs a full CI -cycle per occurrence, and the queue pays it on PRs with no engine content at all. - -**Value 4, not 6.** The ladder caps Developer Experience & CI at 4, and the workaround is real and -cheap - re-run the leg. It is not a production blind spot and touches no shipped default. - -**The observation.** `#281`'s `test (windows-2025, py3.14)` leg, run `31226408247`: - -``` -FAILED tests/test_connscale_smoke.py::test_connscale_smoke_end_to_end -AssertionError: fixed_aggregate@N=24: 255.9 < prior 435.4 * 0.75 ratio 0.588 -1 failed, 10764 passed, 830 skipped in 1849.42s (0:30:49) -``` - -**This is NOT #1096.** The `Tests (pytest)` STEP ran 30:59 against the 36:00 cap and **failed** rather -than being killed. #1096 is the cap; this is an assertion failing well beneath it. `ci.yml` already -records sessions substituting the job reading for the step reading while triaging that leg - reading the -job here gives ~32 minutes and invites the wrong cause. - -**Reproduced locally by adding CPU contention and nothing else** - same commit, same box, same config: - -``` -CI (contended runner) 0.588 -local replicate 1 0.674 -local replicate 2 0.451 -local replicate 3 0.590 -local replicate 4 (PASS) 2.49 -``` - -A 0.75 threshold cannot discriminate inside a 0.451-2.49 spread. **The gate is a coin flip under load, -not a detector.** - -**Mechanism.** The reload probe fires at `hold*0.5` (`harness/load/connscale/runner.py:384-385`) while -the sampler is still running (`:389`), and performs a serial O(N) quiesce-and-swap under `_reload_lock` -(`messagefoundry/pipeline/wiring_runner.py:3518-3545`). Measured under contention: **0.124s at N=12, -3.63s at N=24** - longer than the entire 1.5s hold. It halts the commits that drive `wake_fanout`, -which is roughly 90% of the metric's numerator. Disabling `reload_probe` under identical contention -flips the result to a pass (274.6 -> 282.8, ratio 1.03). - -**The sharp part: the corrupting probe is already exempt from assertion.** -`tests/test_connscale_smoke.py:182-188` exempts that same reload probe from per-step assertion, in its -own words *"stricter than the probe's own contract and flakes on slow CI runners"*. Its O(N) cost is -nevertheless loaded in full onto `empty_claims_monotonic`, which **is** asserted per step. The suite -declined to gate a cost and then gated a high-variance proxy for it. - -**The engine was correct in the failing arm.** `no_loss` asserts at `:162-164`, **before** the SLO at -`:170`, and the reported failure is the `:170` message - so at N=24 every message was received, -delivered and drained. In the local reproduction the N=24 arm was **better** on what matters: drain -0.801 -> 0.575s, achieved_read 4.53 -> 5.38/s over the identical window. - -**`fd_count_monotonic` is not a control for this.** `handles_peak = max(handles)` -(`harness/load/connscale/runner.py:963`) is a peak **count** with no time denominator, so it is -structurally immune to the time dilation that is the entire question. Its passing proves 24 sockets -opened and nothing else. Do not read it as evidence the arm was healthy. - -**Why it surfaces now.** `#1014` removed `@pytest.mark.flaky(reruns=2)` from this test (commit -`1d988fdc`) so that a genuine cross-worktree port collision would surface red instead of self-healing. -That change is correct. The side effect is that runner-variance failures in the same test also surface -red, where the retry used to absorb them - and this test's own comment at `:166` already describes the -SLO as *"a LOOSE >= per mode; CI runners are noisy"*. The absorber was removed without adjusting the -assertion it was absorbing for. - -**The fix: assert empty claims PER MESSAGE, not per second.** Under `fixed_aggregate`, `sent` is -constant across N (36 at both, measured), so per-message is exactly the per-commit herd size that the -mode's own docstring (`harness/load/connscale/profile.py:9-11`) says it exists to measure, and it is -immune to wall clock. Healthy local readings: **39.1 at N=12, 77.8 at N=24** - a clean 2.0x against a -0.75 floor. If the O(N) reload cost is worth gating, **gate `reload_seconds` directly** rather than -through an empty-claim rate. - -**Two mechanisms that look right and are WRONG.** Recorded so they are not re-derived: - -* *"255.9 is below the 288/s do-nothing floor, therefore impossible."* Inverted. `3N/poll_interval` is - a **ceiling** on the idle component, not a floor on the total - a woken worker is preempted and never - books an idle timeout. Measured idle ran at 38% of that number on a healthy box. -* *"the wall-clock window dilates with N."* Not the operative term. Spans measured 2.646 vs 2.649s - unloaded and 6.85 vs 6.51s contended - essentially equal at both N. In the failing runs the - **numerator collapsed**; the denominator did not grow. - -The conclusion survives both; those two arguments do not. - -**LATENT, dormant today, worth fixing in the same pass.** `_monotonic_slo` groups only by `sweep_mode` -(`harness/load/connscale/runner.py:1084-1086`) and chains `prev_val` across the count-sorted group. A -profile setting `claim_modes = ["per_lane","pooled"]` with `empty_claims_monotonic = true` would -chain-compare **across claim modes**, and `harness/load/connscale/compare.py:22-25` states that -pooled's empty-claim rate **should** be materially lower. No shipped profile combines them, so this -cannot fire today - but the grouping is wrong independently of the metric change above. - -**Related:** #1096 (the same leg, a different and genuinely distinct cause - do not merge the two -stories), #1014 (removed the retry that had been absorbing this class), #1000 (a control green because -its evidence could not see the class it covered - `fd_count_monotonic` here is the same shape). - -**Source:** found 2026-08-07 when `#281`'s windows-2025 leg failed on a docs-and-link-checker diff with -no engine content. Causal exclusion first (the diff reaches no engine code; the suite is serial per -`pyproject.toml` `addopts`, so the PR's new test collects **after** `test_connscale_smoke.py` and had -not run), then reproduced under contention rather than argued. The investigating session retracted two -of its own mechanisms, above, before the conclusion was accepted. - ## 1103. the connscale API port range is derived by increment from a single probed port, so every port after the base is unverified > 🔢 **Filed 2026-08-08 - not started. Observed failing on `main`'s own CI, not hypothesised.** Value **4/10** · Difficulty **2/10**. `tests/test_connscale_smoke.py` probes **one** free API port and `harness/load/connscale/runner.py:162` then binds `api_port + step` for every sweep step. Only the base was ever checked. A taken port anywhere in that range kills the engine at startup, and on Windows it surfaces as `WinError 10013` -- *access forbidden*, not the `10048` that reads as a collision -- so the failure does not look like a port problem at all. @@ -7037,67 +5714,6 @@ covered -- the comment quoted above is that shape in prose). call site rather than by re-running: the failing port lies outside the inbound window, which is what rules out the family `#1014` already fixed and points at the one it did not. -## 1104. the DATABASE connector never closes its cursors, so a pooled connection is returned busy and the source's mark fails, emitting a duplicate - -> ✅ **SHIPPED 2026-08-08 — found and fixed in the same pass; reproduced against a real SQL Server 2022 container, not inferred.** Value **6/10** · Difficulty **2/10**. `messagefoundry/transports/database.py` opened a cursor at **five** sites and closed it at **none** — `cur.close()` appeared nowhere in the file. aioodbc/pyodbc keep the ODBC statement handle open until the cursor is closed, so every one of those connections went back to the pool **busy**, and the next caller's first command failed with `HY000 Connection is busy with results for another command`. **This is delivery semantics, not tidiness:** the usual victim is the DATABASE source's `mark`, and `_poll_once` treats a failed mark as at-least-once — the row is left unmarked and **re-emitted as a DUPLICATE**. - -**Cluster:** Connectors / delivery semantics. **Priority:** P2. **Verdict:** built. **Severity:** no PHI -effect. A shipped connector emits duplicate messages on SQL Server whenever the pool hands back a dirty -connection at the wrong moment. Per CLAUDE.md §0 this is stated in the conditional: **a deploying site -running a DATABASE source against SQL Server would see duplicates**, at a rate set by pool reuse. - -**Observed on `main`**, not on a branch: - -``` -DATABASE source mark failed (row will re-emit, a duplicate): - ('HY000', '[Microsoft][ODBC Driver 18 for SQL Server]Connection is busy with - results for another command (0) (SQLExecDirectW)') -FAILED tests/test_database_source_integration.py::test_source_polls_and_marks_rows -assert [(1, 1)] == [(0, 2)] # 1 row left unmarked -> it re-emits -``` - -**Mechanism.** `_select` runs the poll and `_mark` runs an `UPDATE`; both release the connection in a -`finally` without closing the cursor. An `UPDATE` leaves a row count pending on the statement handle, -so the connection is dirty when it returns to the pool. The failure then lands on **whatever statement -next draws that connection**, which is why it reads as unrelated and intermittent. - -**⚠️ THE ERROR APPEARS ON THE INNOCENT STATEMENT.** The command that fails is not the one that left the -handle open. Triaging the reported statement leads nowhere; the cause is one connection-checkout -earlier. That misdirection is the whole reason this survived. - -**Why CI never caught it on `main`.** The `sql server (store + connector)` leg is gated on server-DB and -docker path changes, so it is **skipped on every `main` push** — measured across the five most recent. -It runs only on PRs that touch those paths, which is how a real defect sat on `main` while the leg that -detects it stayed green-by-absence. That is the #1000 shape at the workflow level: a check whose silence -is mistaken for a pass. **Filing this does not fix that**; the leg's `main` coverage is a separate -question and is NOT addressed here. - -**The fix.** A `_close_cursor` helper, called before `pool.release` at all five sites. It never raises: -a close failure must not mask the caller's real error, and must not skip the release that follows — -leaking a pooled connection to save a cursor is the worse trade. - -**⚠️ BE HONEST ABOUT THE INTEGRATION EVIDENCE — IT IS WEAK ON ITS OWN.** Measured on the container: -**1 failure in 10 runs** on the unfixed tree, **0 in 10** with the fix. At a ~10% base rate that -difference is **well inside chance** and proves nothing by itself. It is recorded as the reproduction -that found the defect, not as the evidence that it is fixed. The evidence is -`tests/test_database_cursor_close.py`, which asserts the ordering **deterministically** against a fake -pool and was **verified to go RED on a mutant** with the closes removed (2 of 3 tests failed; the third -covers `_close_cursor`'s own contract and correctly did not). A guard with a 10% detection rate is not -a guard. - -**Related:** #1000 (a control green because its evidence could not see the class it covered — both the -skipped CI leg and the racy integration test are that shape), #1103 (found the same day, also a harness -/ connector defect whose error message points away from the cause), ADR 0003 (the aioodbc choice this -rides on). - -**Source:** found 2026-08-08 while triaging PR #253's red SQL Server leg. #253 was exonerated **by -measurement** — the same test fails identically on `main` — after first being exonerated by mechanism -(that step runs an explicit path list, so `testpaths` cannot reach it). The two reds on #253 were two -*different* unrelated failures, which is why "it failed twice, so it is real" would have been the wrong -read. Verified against a Docker SQL Server 2022 container after first confirming the host actually -reaches the container and not the native `MSSQLSERVER` service also running on that box: both listeners -on 1433 were Docker processes, and `SERVERPROPERTY('MachineName')` returned the container's own -hostname. That check is not optional on this machine. ## 1105. `harden_kex_groups`' docstring undercounts its own call sites, in the paragraph written to warn about exactly that > 🔢 **Filed 2026-08-08 - not started. Measured on `main` at 166634c9, not hypothesised.** Value **3/10** · Difficulty **1/10**. `messagefoundry/config/tls_policy.py:125` says `APPROVED_KEX_GROUPS` reaches "zero of this function's **six** call sites"; `:136` repeats "a call at **six** sites with zero effect". Scanning `messagefoundry/`, `tests/`, `harness/`, `packaging/` and `ide/` for `harden_kex_groups(` finds **seven** sites that build and harden a real TLS context, plus an eighth reference added by `#338`. Nothing checks the number - the tests derive their site list instead - so the docstring is the only place it is asserted, and it is wrong. @@ -8795,248 +7411,6 @@ filing. **Source:** filed 2026-08-08 from the ASVS ledger-coverage sweep of the partial and fail cells that carried no backlog item at all; this cell was one of them. The scorecard is the record of record for the verdict; this item tracks the research toward changing it. -## 1200. the CI docs-only detector exempts EXECUTABLE files under `docs/` from the entire suite - -> ✅ **CLOSED 2026-08-10 — confirmed by reading the shipped workflow, not the commit message.** `.github/workflows/ci.yml` carries `alwayscode='\.(py|ps1|sh|ts|js|yml|yaml|toml|lock|cfg|ini)$'` and evaluates it in the FIRST `elif`, ahead of both `alwayscodepath` and `noncode`. Re-driven 2026-08-10 with the regexes read back out of `ci.yml`: `docs/security/asvs-apply-cells.py` -> code, `docs/SECURITY.md` -> NON-CODE, `.gitignore` -> code. `tests/test_ci_docs_only_detector.py` 23 passed. Filed 2026-08-09 - FIXED in the same change. Value **7/10** · Difficulty **2/10**. `ci.yml`'s `changes` job short-circuits the required `test` legs when every changed path is docs-only. `^docs/` is an alternation branch in that allowlist, so it matches a **`.py` under `docs/`** and short-circuits before the stated `*.py` rule is ever reached. A PR touching only such a file set `code=false` and skipped install, lint, type-check and the whole of pytest. - -**Cluster:** CI correctness / gate blindness. **Priority:** P2. **Verdict:** build (done). -**Severity:** no product effect and no PHI effect. The cost is that a defect here does not fail loudly -- it REMOVES the thing that would have failed, which is the worst failure mode a gate has. - -**Measured, not reasoned.** Extracting the live regex from `ci.yml` and running real `grep -E`: - -``` -PRE-FIX (noncode only): - docs/security/asvs-apply-cells.py -> NON-CODE (suite skipped) - docs/benchmarks/.../b5_microbench.py -> NON-CODE (suite skipped) -POST-FIX (alwayscode checked first): - docs/security/asvs-apply-cells.py -> code - docs/SECURITY.md -> NON-CODE (still short-circuits) - .gitignore -> code (via the noncode branch, BACKLOG #327) -``` - -**Blast radius.** Engine: 2 files, both benchmark scripts under -`docs/benchmarks/results/2026-07-04-adr0071-b5-executor-marshaling/` - low risk. Vault: 3 files, -including `docs/security/asvs-apply-cells.py`, the tool that WRITES the ASVS record of record and can -silently un-close an owner-closed cell. **Two mypy errors had been sitting in that file since it was -written; they could not have survived a single check.** That is the corroboration that the exemption -was real and not theoretical. - -**TWO THINGS MAKE THIS WORSE THAN A MISSING TEST.** - -**The comment and the regex disagree, and the comment is what people read.** `ci.yml` states the intent -in as many words: *"Anything outside the allowlist - any `*.py`, `ide/**`, config, lockfiles, OTHER -workflows, scripts, samples, harness - counts as CODE and runs the full suite."* The regex does not -implement that sentence. An auditor reads the comment, agrees with it, and moves on. - -**The precedent sits four lines above the defect.** `#327` fixed exactly this shape for `.gitignore` - -allowlisted as docs-only, so a `.gitignore`-only PR skipped `tests/test_private_paths_stay_ignored.py`, -*"the one guard that would catch the rule being deleted DID NOT RUN, on exactly the PR shape it exists -to catch"* - and the lesson was written down in place. The identical defect for `docs/**/*.py` was in -the regex immediately below that paragraph. **The instance was fixed and the class was left open, with -the reasoning that would have closed it preserved alongside.** That is the recurring shape: a fix that -does not generalise is the one that comes back. - -**The fix.** An `alwayscode` EXTENSION check evaluated BEFORE the `noncode` allowlist: -`\.(py|ps1|sh|ts|js|yml|yaml|toml|lock|cfg|ini)$`. An executable file is code wherever it lives. The -docs-only optimisation is deliberately preserved for actual documents - simply deleting `^docs/` would -have run the full suite on every prose edit, which is the cost the short-circuit exists to avoid. - -**The test drives the DETECTOR, and reads its regexes OUT of `ci.yml`.** A test carrying its own copy -of the pattern passes forever while the workflow drifts underneath it, reproducing this very defect one -level up. It asserts the regression in BOTH directions in a single test - the pre-fix logic classifies -`docs/x.py` as non-code AND the post-fix logic does not - because asserting only the new behaviour -cannot distinguish a fixed detector from a deleted one (`return True` passes that). It carries a -negative control, so a regex that accidentally matched everything cannot make every assertion pass -vacuously. - -**Source:** found 2026-08-09 while promoting the ASVS writer out of `docs/security/` (BACKLOG #1200's -sibling work), and escalated from instance to class by the parallel `asvs-tracking-rework` session, -which measured the blast radius in both repos and identified the `#327` precedent. -## 1201. `redacted_settings` served credential-bearing HTTP headers outside a five-name list - -> ✅ **CLOSED 2026-08-10 — confirmed in the shipped code, not from the report.** `messagefoundry/config/wiring.py` `_is_secret_header()` now ends `return any(tok in low for tok in _SECRET_HEADER_SUBSTRINGS)` over `auth|token|secret|credential|password|passphrase|key`, with `_SECRET_HEADER_NAMES` kept as an explicit floor (`cookie` matches no substring rule), a `_NOT_SECRET_HEADER_SUFFIXES` exclusion, and a second VALUE arm (`_looks_like_a_credential_value`: RFC 7235 scheme prefixes + JWT shape) for opaque vendor names. `tests/test_connection_factory_redaction_domain.py` 58 passed. **The route-onward below is NOT closed by this** - see the residual. Filed 2026-08-09 - FIXED IN THE SAME CHANGE, and the entry is published WITH the fix rather than ahead of it. Value **8/10** · Difficulty **2/10**. Header redaction was `str(k).lower() in _SECRET_HEADER_NAMES` -- an exact-membership test against **five** strings (`authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `cookie`). Header names are **operator-authored free text**, typed into `connections.toml` or a Handler, so an exhaustive list cannot exist even in principle. Measured against the shipped list: `X-Auth-Token`, `X-Amz-Security-Token` and `Private-Token` were all returned VERBATIM. - -**Cluster:** Security / secret disclosure. **Priority:** P1. **Verdict:** build (done). -**Severity:** on a first deployment, an operator who configured an outbound connection with a bearer -credential in any header outside those five would have had it returned by -`GET /connections/{name}/metadata` to any caller holding `Permission.MONITORING_READ`, and printed by -`graph --json` to stdout, a CI log and the IDE graph view. No PHI. Conditional, per the not-deployed -posture -- but the exposure needs no deployment to be *published*, which is why this entry ships with -its fix. - -**Measured before and after, both serializers:** - -``` -BEFORE: X-Auth-Token, X-Amz-Security-Token, Private-Token -> value returned verbatim -AFTER : all redacted to *** on redacted_settings AND display_settings -KEPT : Content-Type, Accept, User-Agent, X-Correlation-Id, X-Request-Id, - X-Forwarded-For, X-Api-Version, Idempotency-Key -> still readable -``` - -**This is `#1106` one surface over, and structurally worse.** `#1106` was a settings key that a factory -renamed across the parameter/setting boundary; settings keys at least come from function signatures and -are therefore *enumerable*. Header names come from an operator's keyboard. A listed domain was never -going to cover them, so the test is now by SHAPE -- a substring rule over -`auth|token|secret|credential|password|passphrase|key` -- with the original five kept as an explicit -floor, because `cookie` matches no substring rule and must stay named. - -**Erring toward redaction, deliberately, with the cost stated.** A false positive costs an operator one -masked value in a diagnostic view and one line in the not-a-secret list. A false negative serves a -bearer credential to a monitoring reader. The asymmetry is not close. Two exclusions keep the -diagnostic view usable: a suffix rule (`-id`, `-url`, `-uri`, `-name`, `-type`, `-version`, `-agent`, -`-for`), because an `-id` NAMES something rather than being it; and an exact list for -`Idempotency-Key`, which carries "key", is a client-generated request identifier, and is published in -the API docs of every service that uses it. - -**Found by generalising the `#1106` guard rather than by a report.** `#1106`'s fix added a test that -enumerates the redaction DOMAIN by AST and executes the real redactor against every member. The obvious -next question -- "does the sibling control have the same shape?" -- took one probe. That is the whole -method: the defect class is *a control whose domain is narrower than its surface*, and the way you find -the next instance is to ask which other control quantifies over a domain it does not derive. -`tests/test_connection_factory_redaction_domain.py` now covers both. - -**Route onward, NOT closed by this — PENDING OWNER LEDGER DECISION (G28).** The shape rule is a heuristic -over a free-text domain, so it is a floor and not a proof: a header named without any of those substrings -(`X-Shared-Signature`, a vendor-specific opaque name) still passes the NAME arm. The durable fix is for the -header value to never reach a serializer resolved -- the `env()`-only treatment `body_secret_value_*` -already gets -- and that is a larger change than this one. - -> **Residual carried forward 2026-08-10, deliberately un-numbered.** Closing this item closes the -> five-name membership defect; it does **not** close the route-onward above. Whether that residual becomes -> its own backlog number, folds into #1206's sibling residual (both are the same *"nested/free-text values -> are never `env()`-resolved"* shape), or is accepted as-is **is the owner's call, not the archiver's** — -> so no number was allocated for it here. The mitigation actually shipped is the second (VALUE) arm of -> `_is_secret_header`, which catches an opaque-named header carrying a `Bearer`/`Basic`/JWT value; a header -> both opaquely named *and* opaquely valued remains outside both arms by construction. - -**Source:** found 2026-08-09 while probing for a second instance of the `#1106` class before building a -generalised check, on the reasoning that a meta-check built from one instance is shaped like that -instance. Two domains were probed; this one leaked. - -## 1206. `redacted_settings` served ODBC driver credentials sitting in `odbc_params` - -> ✅ **CLOSED 2026-08-10 — confirmed in the shipped code, not from the report.** `messagefoundry/config/wiring.py` `redacted_settings()` now carries an `elif name == "odbc_params" and isinstance(value, dict)` arm emitting `{k: ("***" if _is_secret_odbc_key(k) else v) ...}`, and `_is_secret_odbc_key()` is shape-based and case-insensitive over `pwd|password|passwd|secret|token|credential|passphrase`, with `_NOT_SECRET_ODBC_KEYS` keeping the libpq PATH keywords (`sslkey`/`sslcert`/`sslrootcert`/`sslcrl`) readable. `display_settings` inherits it by delegation. **This is a DISPLAY fix; the storage residual is NOT closed** - see below. Filed 2026-08-09 - FIXED IN THE SAME CHANGE, entry published WITH the fix. Value **8/10** · Difficulty **3/10**. `redacted_settings` masks flat scalars and descended into `headers` alone, so a credential inside `odbc_params` was returned VERBATIM by `GET /connections/{name}/metadata` behind `MONITORING_READ` and printed by `graph --json` - on the SAME object whose top-level `password` masked correctly. - -**Cluster:** Security / secret disclosure. **Priority:** P1. **Verdict:** build (done). -**Severity:** on a first deployment, an ODBC driver password would be served to any monitoring reader -and written to stdout, a CI log and the IDE graph view. No PHI. - -**Measured, both serializers, before and after:** - -``` -BEFORE: odbc_params={"PWD": S, "sslpassword": S} -> both returned verbatim - password="p" on the same object -> '***' -AFTER : PWD, sslpassword, Password -> '***' - Encrypt, ApplicationIntent, - TrustServerCertificate, sslkey (a PATH) -> still readable -``` - -**IT IS NOT MERELY OPERATOR MISUSE, WHICH IS WHY IT MASKS RATHER THAN WARNS.** The docstring says -`odbc_params` "carries only static driver keywords", and the typed fields carry exactly ONE credential -(`username`/`password`, key names configurable via `odbc_user_key`/`odbc_password_key`). But -`_reject_envref_odbc_params` refuses `env()` there. So a connection needing a SECOND driver credential -- libpq `sslpassword` beside `PWD` - has no typed home and no `env()` form, and the inline literal is -the only expressible shape. **A refusal that removes the SAFE expression while leaving the UNSAFE one -is not a mitigation.** - -**THIS IS A DISPLAY FIX, NOT A STORAGE FIX — and the storage half is PENDING OWNER LEDGER DECISION -(G28).** Stated because the difference matters and is easy to lose. The credential remains an inline -literal in the config file. Keeping it out of the file needs `env()` to work here, which needs nested -settings to be env-resolved. That changes the resolution path and what `_reject_envref_odbc_params` -means, so it is the **route-onward** and is deliberately not folded in. - -> **Residual carried forward 2026-08-10, deliberately un-numbered.** `env()` resolution inside nested -> settings is the sibling of #1201's route-onward — the same *"a value inside a container is never -> `env()`-resolved, so the safe expression does not exist there"* shape, which is why they are named -> together rather than separately. Whether this earns its own number, merges with #1201's, or is accepted -> is the **owner's decision**; no number was allocated for it here, and it is not being quietly closed as -> prose. What IS closed is the disclosure: on a first deployment the value would no longer reach -> `/metadata` or `graph --json`. - -**A THIRD PREDICATE, AND THE FIRST ATTEMPT PROVES WHY.** I reached for `_is_secret_setting` - and it -returns False for every one of `PWD`, `Password` and `sslpassword`, because it matches a fixed -frozenset of MessageFoundry SETTINGS names while these are ODBC DRIVER keywords with different -spellings and different case. **A fix shipped on that predicate would have masked nothing while reading -as a fix**, inside the change closing a defect whose whole shape is a control whose domain is narrower -than its surface. `_is_secret_odbc_key` is shape-based and case-insensitive; `pwd` is listed explicitly -because it is an abbreviation matching no substring rule. - -**THE GUARD WRITTEN AGAINST THIS CLASS WAS GREEN OVER IT, AND THAT IS THE REAL FINDING.** -`tests/test_connection_factory_redaction_domain.py` filtered its AST-derived domain through -`_decorator_style`, keeping **4 of 23** spec-returning functions and dropping every base constructor -including `Database`. Its docstring asserted "no shipped factory emits a nested container beyond those -declared below" and called the hole "THEORETICAL rather than live". **Both false.** That claim is -DELETED rather than softened - a number a test has not established has no business in the file defining -the test, and a hedged version keeps the authority while losing the falsifiability. - -The domain is now all 23, and `test_the_domain_covers_every_spec_returning_function` fails if any -discovered function is missing from it. Every other control in that file answers *is this instrument -working* - make it fail on purpose, confirm the injection landed, run a negative control, assert it -examined something. **None of them answers *is it pointed at the whole thing*.** The domain is a -separate claim and now carries its own evidence. - -**Found on the way, and worth more than the fix:** `Http` and `Soap` REFUSE an inline intake -credential outright and demand `env()`, so the value never resolves into settings and no serializer can -leak it. That is the stronger control `odbc_params` lacks, and it is now asserted by -`test_a_refusing_connector_actually_refuses_an_inline_credential` rather than left as folklore. - -**Source:** found 2026-08-09 by the `asvs-tracking-rework` session's independent assessment of ASVS -15.3.1, which I had recused from because I authored the two fixes bearing on that cell. Reproduced here -by execution before any code changed. This is the fourth instance of the class and the second time a -guard written after the previous instance picked a domain narrower than the surface. - -## 1207. an `env()` ref in a headers table, and a credential in URL userinfo, both escaped redaction - -> ✅ **CLOSED 2026-08-10 — both arms confirmed in the shipped code, not from the report.** `messagefoundry/config/wiring.py`: `_redact_header_value()` opens `if isinstance(value, EnvRef): return {"env": value.key}` — the default dropped for EVERY header, not only credential-shaped ones — and `_mask_url_userinfo()` returns `f"{scheme}//{user}:***@{hostpart}"`, wired into `redacted_settings()` by `elif isinstance(value, str) and name.lower().endswith(_URL_SETTING_SUFFIXES)`, with `_URL_SETTING_SUFFIXES` a NAME set plus suffix rule so bare `proxy_url` is covered. Both reach `display_settings` by delegation. Filed 2026-08-09 - FIXED IN THE SAME CHANGE. Value **7/10** · Difficulty **2/10**. Two holes, both INSIDE surfaces the redactor already claimed to handle. **(b)** the `headers` branch had no `EnvRef` arm, so an `env()` ref in a headers table came back as the RAW object carrying its `default` intact - while the same `env()` on a top-level credential correctly emits `{"env": key}` with the default dropped. **(c)** `url="https://user:SECRET@host"` was returned verbatim by both serializers while `proxy_password` on the SAME object masked. - -**Cluster:** Security / secret disclosure. **Priority:** P1. **Verdict:** build (done). -**Severity:** on a first deployment, both would be served to any `MONITORING_READ` caller and printed -by `graph --json`. (b) discloses a FALLBACK secret - the `env()` default is the value used when the -variable is unset, so it is a credential by construction. No PHI. - -**Measured before and after, both serializers:** - -``` -(b) BEFORE headers={"X-Vendor-Thing": env("acme_key", default=S)} - -> EnvRef(key='acme_key', default='S') raw object, default intact, not JSON-safe - AFTER -> {'env': 'acme_key'} default dropped - control Content-Type: application/json untouched - -(c) BEFORE url=https://user:S@host/y verbatim - proxy_url=http://puser:S@proxy:8080 verbatim - proxy_password on the same object '***' - AFTER url=https://user:***@host/y user, host and path PRESERVED - control https://plain.invalid/path?q=1 untouched -``` - -**WHY THE DEFAULT IS DROPPED FOR EVERY HEADER, not only credential-shaped ones.** The measured -instance used `X-Vendor-Thing`, which matches no substring in the header name rule - so gating the -`EnvRef` arm on that rule would have left this exact case open. A header value sourced from `env()` is -a credential by intent; nobody `env()`-refs a `Content-Type`. The name heuristic is the wrong gate -here, and it is precisely the gate that failed. - -**WHY THE USER, HOST AND PATH SURVIVE.** Only the password half of the userinfo is replaced. An -operator diagnosing a connection needs to see which account and which host; masking the whole URL -would destroy the view rather than protect it, and nothing would report that as a loss. The control -test asserts a URL without userinfo is left byte-identical, because a masker that rewrites every URL -would satisfy the leak assertions while silently mangling ordinary configuration. - -**`proxy` is another parameter-to-setting rename**, noticed while fixing this: the factory parameter -is `proxy` and the emitted setting is `proxy_url`. That is the same boundary `with_signing` crosses -(`private_key` -> `sign_private_key`, BACKLOG #1106) - which is why the URL rule is a NAME set plus a -suffix rule rather than a suffix rule alone. - -**Source:** both found by the `asvs-tracking-rework` session's independent assessment of ASVS 15.3.1, -alongside the `odbc_params` disclosure fixed as #1206. Reproduced here by execution before any code -changed. With these closed, the three surfaces that hold 15.3.1 at `partial` are addressed and the cell -is due a re-read - by that session, not by me, since I authored all three fixes. - -**Process note against myself:** the code comments in this change cited `#1207` BEFORE the number was -allocated. It happened to be next, so nothing collided - but "happened to be next" is exactly the -reasoning `scripts/coord/alloc.ps1` exists to eliminate, and two sessions doing it simultaneously is -the documented failure. Allocate, then write. - ## 1208. no guard asserts that a credential factory PARAMETER maps to a SETTING name the redactor covers > 🔢 **Filed 2026-08-09 - not started. THREE MEASURED INSTANCES of one shape, not a hypothesis.** Value **7/10** · Difficulty **4/10**. A connector factory takes a credential parameter and emits it under a DIFFERENT setting name. Every redaction control operates on the SETTING name. Nothing asserts the two agree, so a rename silently moves a credential outside the control's domain. @@ -9079,85 +7453,6 @@ which means it cannot catch the next one. Follow the VALUE. **Source:** raised by the `asvs-tracking-rework` session on 2026-08-09 after `proxy` -> `proxy_url` became the third instance: *"that is not a coincidence to note in a residual; it is an argument that the rename boundary itself needs a guard"*. Filed before it was forgotten, per that session's request. -## 1209. the dependency advisory guard inverts to FAIL-OPEN when the advisory API errors - -> ✅ **CLOSED 2026-08-10 — confirmed in the shipped workflow AND re-executed against a `gh` stub.** `.github/workflows/dependabot-auto-merge.yml` now reads `--jq '[.[] | select(.withdrawn_at == null)] | length' 2>/dev/null)" || count="ERR"` — the `||` binds the ASSIGNMENT, outside the substitution — followed by the shape test `case "$count" in ""|*[!0-9]*)`. Re-run 2026-08-10 under `bash -e` with a stub reproducing the stream split (JSON body to stdout, `gh:` line to stderr, exit 1): the pre-fix form (`|| echo "ERR"` inside + equality sentinel) leaves `count={"message":"API rate limit exceeded",...}ERR`, misses the sentinel, errors "integer expression expected" and emits **advisory_ok=true**; the shipped form leaves `count=ERR` and emits **advisory_ok=false**. Filed 2026-08-09 - FIXED IN THE SAME CHANGE, entry published WITH the fix. Value **9/10** · Difficulty **2/10**. Guardrail #2 of `dependabot-auto-merge.yml` read `count="$(gh api ... || echo "ERR")"`. The `||` runs INSIDE the command substitution, so it APPENDS to stdout rather than replacing it - and `gh api` copies the JSON error BODY to stdout on any HTTP error. The sentinel `[ "$count" = "ERR" ]` therefore misses, and the guard emits `advisory_ok=true` for a lookup that never succeeded. - -**Cluster:** CI / supply chain. **Priority:** P1. **Verdict:** build (done). -**Severity:** unlike the redaction items above, this is not conditional on a first deployment - the -workflow runs in CI today. What bounds it is narrower and worth stating exactly: the engine's merge -condition also requires `age_ok`, and the age step returns false for every ecosystem that can be -`eligible`, a disjointness the file documents about itself. So the falsely-true `advisory_ok` cannot -ALONE merge anything as shipped. It flips a security decision the workflow publishes, and the file -labels the surviving blocker "a FORWARD guard ... load-bearing the day a Python allow row is -populated" - one line's edit away from making this directly merge-affecting. - -**The mechanism, reproduced end to end against the shipped step body:** - -``` -gh api on any HTTP error: JSON body -> STDOUT, "gh: ... (HTTP nnn)" -> stderr (eaten by 2>/dev/null) - count = '{"message":"API rate limit exceeded","status":"403"}ERR' - [ "$count" = "ERR" ] || [ -z "$count" ] -> MISSES (neither) - [ "$count" -lt 1 ] -> "integer expression expected", returns 2 - -> an `if` CONDITION is exempt from `set -e` - -> "::notice::published advisory confirmed", advisory_ok=true, step exits 0 -``` - -Measured by running the real `ghsa` body from `origin/main` and from the fix, under `bash -e`, with a -`gh` stub reproducing the stream split: - -``` - gh ERRORS gh returns 1 -pre-fix (main) advisory_ok=true advisory_ok=true <- FAIL OPEN -fixed advisory_ok=false advisory_ok=true <- fails closed, happy path intact -``` - -**A stub that merely exits non-zero would have proved nothing** - it would pass against the defective -code too. The defect is that the BODY reached the variable, so the stub has to write the body. - -**Where that test actually runs, stated because a skip is not a pass.** The three -`test_the_advisory_guard_fails_closed_when_the_api_errors` rows execute the shipped `run:` body and -therefore need `bash` **and `jq`**. On the maintainer's box Git Bash ships no `jq`, so all three -**SKIP** locally and the file reports `27 passed, 7 skipped` - a green local run that has not exercised -this guard at all. They run on the ubuntu leg and on the two required `windows-2022`/`windows-2025` -legs, whose images carry `jq`. The 2026-08-10 closure therefore did not rest on that local green: the -pre-fix and shipped guards were re-executed by hand under `bash -e` against a body-writing stub, which -needs no `jq`. - -**The comment directly above the defect asserted the opposite:** "Fail closed on any error", and the -header, "a rate-limit/API error or no-matching-advisory routes to manual review, never auto-merge." -A compensating control resting on a false premise, which is the shape SDS-3.7 names. - -**The existing test could not see it.** `test_ghsa_step_queries_the_advisory_api_and_emits_a_guard` -asserted the STRING `"advisory_ok=false" in body` - satisfied by a step that merely CONTAINS the words, -and the fail-open lived underneath a passing version of exactly that check. The file already had the -right instrument: `_run_step_body` executes shipped `run:` bodies under `bash -e` and returns the -parsed `$GITHUB_OUTPUT`. Guardrail #2 was the one guard not using it. -`test_the_advisory_guard_fails_closed_when_the_api_errors` now executes the body across three rows, -including a discriminating PASS so the suite cannot be satisfied by a step that denies unconditionally. - -**The domain, because fixing one instance is how this class survives:** a sweep of 63 workflow and -script files across both repositories found 24 instances of the idiom - 16 provably harmless (`git -rev-parse --verify --quiet` writes nothing on failure), and the rest fixed here. Moving the `||` outside -the substitution also fixes the streaming cases for free: jq emits rows before a mid-array error, and -the old form would have appended the sentinel to a TRUNCATED dependency list while still reporting -success. Assigning on failure discards partial output instead of inheriting it. - -The `count` guard additionally moved from an equality test against one sentinel to a SHAPE test -(`case "$count" in ""|*[!0-9]*)`). An equality test recognises exactly the failure it was told about, -which is how a JSON body walked through it; the numeric comparison's real question is "is this a -number", and only a shape test answers that for values nobody anticipated. - -**Sibling, same idiom, in the private scorecard repo:** its `asvs-verifier-drift.yml` mirror-decision -step fails the opposite way - `remote_tip` holds the 404 body instead of the empty string, so it -refuses to decide on EVERY run where the mirror branch does not exist, which is the steady state. That -one fails closed and is therefore a dead control rather than a disclosure; it is why the daily drift -job has never completed its decision step. - -**Source:** found 2026-08-09 while sweeping for siblings of the drift-workflow defect, after a peer -correctly refuted my first diagnosis of that job's failure (I said the control "detected drift and -could not act"; the scheduled run predated the drift by 88 minutes and its parity step passed - the -control has never yet detected this class at all). ## 1210. the connscale FD/RSS peak has no provenance, so a stale-ppid subtree adoption becomes the reported number > 🔢 **Filed 2026-08-09 - NOT FIXED, diagnosis only.** Value **7/10** · Difficulty **5/10**. `test_connscale_smoke_end_to_end` failed CI on `fd_count_monotonic` with `fixed_per_conn@N=24: 344 < prior 3.56e+04 * 0.75`. The engine was almost certainly healthy in both arms: the 35,600 is the artefact. `handles_peak`/`ws_peak` are `max()` over a subtree SUM whose covering PID set is never recorded or validated, so one bad resolution poisons the step and `max()` latches it permanently. diff --git a/docs/archive/backlog/BACKLOG-CLOSED.md b/docs/archive/backlog/BACKLOG-CLOSED.md index 9a95d3be..47afe759 100644 --- a/docs/archive/backlog/BACKLOG-CLOSED.md +++ b/docs/archive/backlog/BACKLOG-CLOSED.md @@ -5679,3 +5679,1793 @@ Either way: add `phi=True` equivalence for this route (a `phi` parameter threade --- --- + +## 228. Steps / config search finds handlers, routers, and transforms by name (not just connections) + +> ✅ **CLOSED 2026-08-05 — both 2026-07-28 remainders built.** Value **4/10** · Difficulty **2/10**. **(a)** Definitions rows now carry a `contextValue` of their own — `meforSymbolHandler` on a handler row — gating the inline **View as Steps** action, which resolves through the row's *file*. They deliberately do **not** borrow `graphModel`'s `meforElementHandler` / `meforElement`, and no row claims an `elementKind` / `elementName`: a row's name is the Python **function** name (`def handle`) while the graph is keyed by the registered **decorator** name (`@handler("acme_adt_handler")`), and every `samples/config/` module makes the two differ — so the element vocabulary would render a **Show in Wiring Map** action that could only ever land on "the focused element no longer exists in the graph". Router / transform / send rows carry no action. **(b)** `SymbolKind` gains `send`: a separate extraction pass (a `Send(…)` sits inside a def body, out of reach of the column-0 def regex) indexes the connection each call addresses, at the call-site line; its comment guard is quote-aware, so a *trailing* `# was Send("OB_OLD", …)` is not a call site while a `#` inside a string literal does not truncate the line. **This is a bound, not a completeness claim:** at least quoted-literal targets and module-level `NAME = "literal"` constants are indexed; at least a computed, imported, or f-string target — and a ruff-wrapped call whose target is not on the `Send(` line — is dropped rather than guessed. **That is NOT the `graph --json` bound:** that extractor marks an unresolvable target `dynamic` and *surfaces* it ([ADR 0091](../../adr/0091-element-centric-connections-view.md) AC-3), and its module-constant rule validates against the whole module, neither of which this flat text scan does. The graph views remain the authority on resolved wiring. Twenty-one new tests, all node-side (so they run on every `ide` CI leg, not only the Windows Extension Host leg), each falsified. + +> **AMENDED 2026-08-05 — both remainders described below are now BUILT; the 2026-07-28 block that follows is the historical record, not current state.** Read it as the finding that scoped this work, not as a live gap. The one correction worth carrying forward: remainder (a)'s diagnosis named `viewItem == meforElementHandler` as the gate to satisfy, and adopting that value verbatim is precisely what the close had to avoid — see the CLOSED banner above. + +> **AMENDED 2026-07-28 — the index IS built; two clauses of the Proposed line are not.** Adversarial verification refuted a full close. **BUILT:** `ide/src/symbolIndex.ts` scans and surfaces handlers / routers / transforms **by name** in the MEFOR view's Definitions section, unit-tested — which fixes the item's headline complaint (a transform is a Python symbol inside a file named for the *connection*, so neither the sidebar search nor Ctrl+P could find it). +> +> ⚠️ **REMAINDER (a): a hit cannot open straight into the Steps view.** The Proposed line asks to reuse the CodeLens / `openSteps` entry point, but Definitions rows carry **no `contextValue`**, so the inline "View as Steps" action — gated on `viewItem == meforElementHandler` — never renders on them, and a click runs plain `openSource`. ⚠️ **REMAINDER (b): "(and the outbound connections a handler sends to)" is outside the index** — `SymbolKind` is `handler|router|transform` only, and the definition regex matches **top-level `def`** only. Both are small; neither is done. + +**Cluster:** IDE & Authoring. **Priority:** P2. **Verdict:** build. **Severity:** low. + +**What:** the MessageFoundry sidebar search (the box over the MESSAGEFOUNDRY view) matches **connection** names only. Searching for a **handler / router / transform** name — e.g. `xform_SITEA_to_erp_mfn` — returns “No matching results” even though that handler exists (it is defined inside `IB_FILE_HR_Materials_SITEA_MFN.py`, a role-combined feed module whose *filename* is the connection, not the handler). VS Code's own `Ctrl+P` also misses it, because the name is a symbol inside a file, not a filename. + +**Why:** operators think in terms of the **transform / message name**, not the feed file it happens to live in. The connection→router→handler wiring is a graph (CLAUDE.md §1), so a user who knows the transform name has no direct path to its definition. It is sharper for the ported migration estate where feeds are still monolithic (see #226): one file holds the connection + router + handler, so the handler name appears nowhere in the tree or the filename. + +**Proposed:** index handlers / routers / transforms (and the outbound connections a handler sends to) by name in the MEFOR view search, and jump to the `@handler`/`@router`/`inbound`/`outbound` definition on a match — reusing the CodeLens / `openSteps` entry point so a hit can open straight into the Steps view. A `lens parse` (or a light `findElements` scan) over the config dir already yields the handler/router names. + +**Source:** owner report 2026-07-11 while previewing the shipped IDE against the ported migration estate — searched a transform name in the MEFOR view, got “No matching results”. Related: #226 (split monolithic feeds, which would also surface handler names as files). + +--- + +--- + +## 235. Generate Steps view parameter forms from Python type hints + +> ✅ **Closed 2026-08-05 -- engine-emitted param schema (`lens schema` CLI) + schema-driven IDE renderer; int-to-number, the retype-trap fix, and enum-to-dropdown (convert_case/pad_field/arith_field/date_diff_field, narrowed to Literal) are all live; code-set picker is N/A (no editable code-set literal to attach to); code/control rows stay read-only.** Value **4/10** · Difficulty **4/10** · _fill-in_. Widens what is *editable* without widening the recognition grammar; sequence deliberately against #237. + +**Cluster:** IDE & Authoring. **Priority:** P2. **Verdict:** build (evaluate as its own lane). **Severity:** low. + +**What:** today a recognized row exposes **enabled inputs only for literal params**; anything else renders visibly disabled (`stepsView.ts:11-13`). Windmill's pattern is to derive a **JSON Schema from the script's Python type hints** and render the step's parameter form from that schema. Applied here: `lens parse` (or a sibling `lens schema`) emits, per recognized action, a small parameter schema derived from the vocabulary helper's own **type hints** — which ADR 0076 §2 already requires to be "fully type-hinted, mypy-strict". + +**Why it is attractive:** it widens what is *editable* without widening the **recognition grammar** — the expensive, ADR-amendment-gated axis. The row set stays exactly as recognized today; only the input widgets get richer (enum → dropdown, `Literal["upper","lower","title"]` → radio, int → number field with validation, code-set name → the existing `codesetList` picker). + +**Build sketch:** engine side, derive the schema from `messagefoundry/actions.py` signatures (stdlib `inspect`/`typing`, no new runtime dep — ADR 0076 §6.5 forbids one in phases 1–2); IDE side, replace the hand-rolled per-op input rendering in `stepsModel.ts` (`ADD_MENU_CATALOG`, `TOOLBAR_INSERT_DEFAULTS`) with a schema-driven renderer. Keep `code`/`control` rows read-only. + +**Open question:** whether the schema is emitted by the engine (one source of truth beside the vocabulary, matching the ADR 0072 L5/L6 split the lens already follows) or hard-coded in the IDE. Engine-side is the consistent choice and is the recommendation to test first. + +**Related:** #222, ADR 0076 §2 (typed, mypy-strict vocabulary), [ADR 0106](../../adr/0106-steps-view-add-dropdown-vocabulary-expansion-adr-0076-phase-b.md) (the 27-item palette this would re-render), #237 (per-argument input modes — same form surface, land them together or in a deliberate order). + +**Source:** Windmill/Kestra evaluation (2026-07-30) — "borrow the idea, not the product"; owner approved testing it as a separate lane. + +--- + +## 238. OpenFlow step-attribute completeness pass over the engine vocabulary + +> ✅ **CLOSED 2026-08-06 — findings note delivered.** Value **1/10** · Difficulty **1/10**. The gap-map lives at [docs/research/openflow-step-attributes.md](../../research/openflow-step-attributes.md); OpenFlow remains explicitly **not** a compatibility target — the note is a vocabulary map, not a gap-to-close list. + +**Cluster:** IDE & Authoring / Engine. **Priority:** P3. **Verdict:** build (a review, not a feature). **Severity:** none — this is a gap-analysis task whose output is findings. + +**What:** read Windmill's **OpenFlow** step-attribute vocabulary as a **completeness checklist** against MessageFoundry's own step/connector semantics, and record what is missing, what is deliberately absent, and what is already covered under a different name. The attributes to walk: `retry`, `timeout`, `stop_after_if`, `skip_if`, `continue_on_error`, `mock`, `cache_ttl`. + +**Explicitly NOT the goal — do not target OpenFlow compatibility.** OpenFlow is an open standard (Apache-2.0, so safe to read and cite) but its `info.version` tracks Windmill's own release tag, i.e. one vendor's weekly train. Emitting or consuming OpenFlow is a **separate** question and is not authorized by this item. Adopting a *declarative artifact* remains declined by ADR 0076 §7 and #26. + +**Expected output:** a short findings note (a research doc or an amendment to this item) listing, per attribute: covered / not covered / deliberately declined, with the MessageFoundry construct that covers it. Some will already be covered engine-side rather than in the Steps view (retry/timeout live in connector + delivery semantics, not in a handler row), and saying so precisely is most of the value. + +**Related:** #222, ADR 0076 §7 (declarative artifact declined), #26 (the visual/declarative-authoring line). + +**Source:** Windmill/Kestra evaluation (2026-07-30); owner approved the checklist framing explicitly ("don't target compatibility"). + +--- + +## 325. Leak gate's home-path detector is case-blind on Windows paths + +> ✅ **Closed 2026-08-05 — shipped in #177 (commit `88703a3a`), an ancestor of `main`.** The fix and its regression tests landed folded into that batch, not on a branch of this item's name. The `_HOME_PATH` drive-letter arm case-folds inline (`scripts/security/scan_forbidden.py:114-121`) — scoped to that arm, so the POSIX `/users/` REST route stays unmatched (whole-pattern `re.I` would have measured 47 false positives) — and the sibling `_WORKTREE_SLUG` folds whole (`:96`); casing fixtures at `tests/test_scan_tokens_source.py:701,731` pass. Value **6/10** · Difficulty **2/10** · _quick win_. + +> **AMENDED 2026-08-05 — the What / Why / Proposed / Source block below is the historical filing record, not current state.** Read it as the finding that scoped this work, not as a live gap: the fix and its regression tests shipped in #177 (see the CLOSED banner above). The `_HOME_PATH` snippet quoted under **What** (a literal `Users`, compiled with no flags) is the PRE-fix pattern; the shipped detector folds the drive-letter arm inline at `scripts/security/scan_forbidden.py:114-121` and the `_WORKTREE_SLUG` sibling folds whole at `:96`. The four-spelling FIRES/MISSED table records the pre-fix behaviour, the **Proposed** steps are all built, and the line anchors together with the "Verified open at HEAD (`12efbffc`)" line reflect the state at filing, not today. + +> **Note on the examples below.** Every path here writes the account segment as the placeholder ``, because `_HOME_PATH`'s negative lookahead exempts a segment beginning `<` — a literal account name in this item would trip the very gate it describes. Read `` as "a real login name"; the FIRES/MISSED column describes what happens once one is substituted. This is [#322](../../BACKLOG.md) in miniature: a placeholder written into tracked prose is itself scanned. + +**Cluster:** Security / Supply chain. **Priority:** P2. **Verdict:** build. **Severity:** medium. + +**What:** `scripts/security/scan_forbidden.py:99-106` compiles the structural home-path detector with **no flags argument**: + +```python +_HOME_PATH = re.compile( + r"(?:[A-Za-z]:[\\/]Users|/home|/Users)[\\/]" + ... +``` + +The drive letter is class-matched (`[A-Za-z]`) but `Users` is a **literal**, so only the canonical casing fires. Measured at HEAD by executing the module's own compiled pattern: + +| probe | result | +|---|---| +| `C:\Users\\proj` | **FIRES** | +| `c:\users\\proj` | **MISSED** | +| `c:/users//proj` | **MISSED** | +| `C:\USERS\\proj` | **MISSED** | + +Windows filesystems are case-insensitive, so all four name the **same** directory and disclose the same OS account. The gate blocks one spelling of it and waves through three. + +This detector is the odd one out in its own module: `[names]` token patterns default to case-insensitive (`scan_forbidden.py:347`, `flags = 0 if case == "s" else re.I`) and the estate file detectors pass `re.IGNORECASE` explicitly (`:440`). The module also states its own tie-breaking rule at `:336-338` — *"under-detection is the dangerous direction … Fail toward more detection"* — which this line violates. + +No test covers it. `tests/test_scan_tokens_source.py:559-583` (`test_absolute_home_path_is_flagged_but_placeholders_are_not`) is the only home-path test, and both its positive fixtures — a `C:\Users\\Code\thing` form and a `/home//src` form, written there with real-looking account segments — are canonical case. Nothing asserts a casing variant in either direction. + +**Why:** `forbidden-content (customer/PHI leak guard)` is a **required merge context** (`.github/required-contexts.txt`), and `.github/workflows/security.yml:449-452` names *"absolute home paths"* among the things it scans the whole tracked tree for. A green run therefore reads to a reviewer as "no internal-environment disclosure present." For the lowercased spelling that reading is unearned. It is a **structural** detector, so it is the control that is supposed to work even in a fork with no token source at all. There is no compensating control: `scan_forbidden.py:10-12` is explicit that gitleaks finds *secrets*, not this class. + +**Bounded honestly — this is latent blindness, not a live leak.** Scanning every git-tracked file at HEAD, the current pattern finds **0** home-path hits and the proposed fix also finds **0**: there is no lowercased home path sitting in the tree right now. The disclosure it fails to catch is an **OS account name** — not a credential, not PHI, not customer data. Nobody needs privilege or an exploit to trip it; the failure mode is a developer pasting a stack trace or a shell transcript in non-canonical case and the gate not noticing. The blast radius is one developer login name reaching a public repo, which is exactly what this detector exists for and no more than that. + +**Proposed:** + +1. Case-fold **only the drive-letter arm**, inline, leaving the POSIX arms alone: + + ```python + r"(?:(?i:[A-Za-z]:[\\/]users)|/home|/Users)[\\/]" + ``` + +2. **Do not reach for whole-pattern `re.IGNORECASE`** — measured, it adds **47 false positives** across the tracked tree, every one of them the web console's `/ui/users/…` REST route (`messagefoundry_webconsole/routes/admin.py:38`, `:91`; `docs/SECURITY.md:575`). That would red the required context on the first run. `/users/` is an extremely common URL path segment; `/Users/` is not. The asymmetry in the current pattern is load-bearing, and the inline form preserves it: measured **0** new false positives. + +3. Keep the exemption list (`Public|Default|runner|me|svc|you|…`) **case-sensitive**. Case-folding it would widen the exemptions on POSIX, where an upper-cased and a lower-cased spelling of the same exempt word are genuinely different accounts — and widening an exemption is the under-detection direction. (Note a pre-existing, unchanged over-match: a Windows path whose account segment is a **lower-cased** spelling of one of those exempt words fires today, because the exemption compares case-sensitively and the lower-cased form misses the literal. That is the safe direction; out of scope here.) + +4. Add the regression case to `tests/test_scan_tokens_source.py:559`, alongside the existing canonical fixtures — a lowercased and an upper-cased Windows path must both produce a hit, and the POSIX `/users/…` non-match should be asserted deliberately so the next person does not "fix" it into the 47-false-positive form. + +5. **Same fix site, sibling defect:** `_WORKTREE_SLUG` at `scripts/security/scan_forbidden.py:92` is case-blind the same way (`[a-z0-9]+`); an upper-cased slug — `claude/` followed by `Some-Task-a1b2c3` — is MISSED. (Written split on purpose, for the reason in the note above: once the fix lands, the joined literal trips the very detector it documents, and unlike `_HOME_PATH` the slug pattern has no `<…>` exemption to write it into.) `scripts/worktree/new.ps1:43,86` passes `-Name` through verbatim with no lowercasing, so an upper-cased worktree name is reachable. Narrower than the home-path case (agent-created slugs are lowercase by convention), but it is a two-character edit in the same block — take it in the same change or say why not. + +**Related:** `scripts/security/scan_forbidden.py` (`_HOME_PATH` :99-106, `_WORKTREE_SLUG` :92, call site :758-759), `tests/test_scan_tokens_source.py:559-583`, `.github/workflows/security.yml:446-493`, `.github/required-contexts.txt`, `scripts/worktree/new.ps1`. Sibling **#321** — same gate, same "green gate that cannot see the class" root cause, but the **opposite mechanism**: #321 is an incomplete *token source* (data, fixed by the owner updating a private secret) and explicitly scopes itself away from scanner defects; this is a *structural detector* defect (code, fixed by a regex edit) that is live even with no token source. Also **#322**, and the anonymizer's structural-detector item from this same audit. Note #321's **Related:** line cites `tests/test_scan_forbidden.py` for regression tests, but the home-path test actually lives in `tests/test_scan_tokens_source.py` — worth correcting when someone next touches #321. + +**Source:** public-repo disclosure audit, 2026-08-01. Verified open at HEAD (`12efbffc`) by executing the compiled pattern and by diffing the current, proposed and naive-`re.I` variants across every git-tracked file. + +--- + +--- + +--- + +## 327. No test asserts the private-path `.gitignore` block still ignores anything + +> ✅ **CLOSED 2026-08-10 — Proposed 1-3 shipped in `dddbdc32`, and the guard was PROVED ABLE TO FAIL rather than merely observed green.** `tests/test_private_paths_stay_ignored.py` pins all six rules in a literal `_PRIVATE_PATHS` list, asserts `git check-ignore -q` on a synthetic probe child plus an empty `git ls-files` per prefix, and carries a `len(_PRIVATE_PATHS) == 6` cardinality assertion so deleting an entry cannot silently delete its coverage. 13 passed. Made to fail on purpose 2026-08-10 by removing `/docs/security/` from `.gitignore`: RED, naming the rule (*"'docs/security/probe-327.md' is NOT ignored"*), restored byte-clean. The CI half is wired — `ci.yml` `alwayscodepath='^(\.gitattributes|\.gitignore)$'`, driven live, classifies a `.gitignore`-only change as **code**, so the guard fires on exactly the PR shape it exists to catch. The prose residual is fixed in the same change as this closure. Filed 2026-08-01. Value **6/10** · Difficulty **2/10** · _quick win_. Six `.gitignore` rules are the sole control keeping maintainer-internal security material out of a public commit since the publish deny-list was retired, and the repo-wide search for `check-ignore` matches exactly one hand-run script (`scripts/dev/setup-leak-gate.ps1:58`) covering a different file, so the boundary is defended by review attention plus a hook that lives inside the now-ignored `/.claude/` tree and no fresh clone gets; a pinned-literal test with a synthetic probe child, plus dropping `^\.gitignore$` from the `noncode` allowlist at `.github/workflows/ci.yml:658` — without that edit the guard goes green on exactly the PR it exists to catch. + +**Cluster:** Security / Publishing boundary. **Priority:** P2. **Verdict:** build. **Severity:** medium. + +**What:** `.gitignore:128` opens the block that replaced the retired publish deny-list, and states its own stakes at `.gitignore:131-132`: + +``` +# repo, a gitignore rule is now the ONLY thing keeping them out of a commit -- and the cutover runbook +# runs `git add -A`. Same failure shape as the leak-scanner token file, different files. +``` + +The rules are `/.claude/`, `/TRANSCRIPTS.md`, `/docs/security/`, `/docs/reviews/`, `/docs/marketing/` (`.gitignore:142-146`) and `/docs/CI-TOPOLOGY.md` (`.gitignore:160` — a **sixth** rule in the same block, in the same posture). All six match at HEAD and no tracked file sits under any of them, both confirmed directly: + +``` +git check-ignore -v docs/security/x.md # -> .gitignore:144:/docs/security/ docs/security/x.md +git ls-files -- .claude docs/security docs/reviews docs/marketing TRANSCRIPTS.md docs/CI-TOPOLOGY.md +# -> (empty) +``` + +Nothing asserts either half stays true. A repo-wide search for `check-ignore` matches exactly two files: the untracked `.claude/settings.local.json`, and `scripts/dev/setup-leak-gate.ps1:58` — which checks **one** path (`scripts/security/scan-tokens.local.txt`), and only when an operator runs that setup script by hand. `scripts/security/scan_forbidden.py` enumerates tracked files (`_git_tracked()`, `scan_forbidden.py:679-683`) but every check downstream is content-based — tokens, IPs, home paths — so it has no opinion about a path. None of `.pre-commit-config.yaml`'s hooks (ledger-gate, ruff, forbidden-content, gitleaks, actionlint, bandit) is path-prefix based, and `ci.yml` / `security.yml` contain no reference to `docs/security`, `publish-denylist`, `private-path` or `check-ignore`. + +The two nearest-looking guards are neither: `tests/test_scaffold.py:51-52` asserts a gitignore substring for the **scaffolded config repo** `messagefoundry init` writes, not for this repo; `tests/test_release_pipeline.py:38`'s `PRIVATE_CANARY = "docs/security/THREAT-MODEL.md"` guards the **sdist/PyPI** channel (the hatchling `only-include` vs `release.yml` leak-gate cross-check), which is a different publication path from `git commit`. + +**Why:** this is the project's own evergreen lesson pointed at the highest-consequence boundary it has — the one deciding whether maintainer-internal security material (threat model, ASVS assessments, point-in-time review findings, per `docs/SECURITY-DOCS-POLICY.md`) is public. It is defended today by a text file nobody checks and by review attention. + +**Bounded honestly — the blast radius is what it is and no more:** + +- **Nothing is exposed right now.** All six rules match and zero files are tracked under them. This is a preventive gap, not a live leak. +- **It is not an attacker-exploitable defect.** Reaching it needs push access to this repo — reordering a rule, adding an un-ignore above one, resolving a merge conflict in the block, or `git add -f`. Anyone with that access could publish those documents deliberately in one commit. The guard defends against **accident and drift**, not against a hostile committer, and should be valued that way. +- **No PHI, no credentials.** The private set is prose about the system's posture. Real secrets are covered separately (`.env`, `*.key`, `*.pem` at `.gitignore` lines above, plus gitleaks in `.pre-commit-config.yaml`). +- **The obvious compensating control does not actually travel.** `scripts/hooks/block-blanket-git-stage.ps1` denies `git add -A` — the exact command `.gitignore:132` warns about — but it is wired through `.claude/settings.json`, which is itself inside the now-gitignored `/.claude/` tree and **untracked** (`git ls-files .claude/settings.json` is empty while the file exists on disk). It is a local Claude Code session control, fail-open by design, absent from a fresh clone or a new `git worktree add`. Do not count it as coverage. + +**Proposed:** + +1. Add `tests/test_private_paths_stay_ignored.py` with a **pinned literal list** of the six rules and two assertions per entry: (a) `git check-ignore -q` exits 0 for a synthetic probe child (`docs/security/__probe__.md`) — probing a synthetic path, not a real private file, so the test is valid in a public checkout where the private tree is absent by definition (the groundedness problem `tests/test_release_pipeline.py:117-127` already worked through); (b) `git ls-files` returns nothing under the prefix, since a gitignore rule never un-tracks a file that got added first. +2. **Pin the list in the test; do not parse it out of `.gitignore`.** Guarding a file by parsing that same file is how this repo already burned itself once — `tests/test_feature_map_claims.py:52-55` records a `.gitignore` marker-block parser whose marker existed only inside the test, exercised against a `tmp_path` fixture: *"It was a check that could not fail."* +3. **Wire it where it will fire on the PR that breaks it.** `ci.yml:472` puts `^\.gitignore$` in the docs-only `noncode` allowlist, so a `.gitignore`-only PR sets `code=false` and the `Tests (pytest)` step (`ci.yml:226-227`) is skipped — a pytest-only guard would go green on exactly the change it exists to catch, and would only fire on the post-merge push to `main`. Fix by dropping `^\.gitignore$` from that regex (a `.gitignore` edit is not a docs edit; it is the publishing boundary), and/or adding a `local` pre-commit hook alongside `ledger-gate` with `always_run: true` / `pass_filenames: false`. The CI arm is the load-bearing one — `.pre-commit-config.yaml`'s own header shows the hooks need a per-clone `pre-commit install`. +4. Consider promoting the resulting context per `.github/required-contexts.txt` rather than adding a paths-filtered workflow — a paths-filtered required check is the required-but-absent trap `manifest-lint.yml` documents. + +**Also (small, same block):** `docs/SESSION-DRIFT-CONTROLS.md:69-71` links to `[.claude/settings.json](../.claude/settings.json)`, which `/.claude/` now ignores and which is untracked — the link cannot resolve in the public repo, and the paragraph presents the blanket-`git add -A` guard as an active control while pointing at a file no public reader has. Fix in the same commit. The stale comment above the `.claude/settings.local.json` rule ("settings.json is shared/tracked") is contradicted by the later `/.claude/` rule at `:142` and should go with it. + +> **DONE 2026-08-10, with one residual named.** The dead link is removed (not repaired — it named a path +> no reader outside the maintainer's machine has) and the paragraph now states the guard's real reach: +> the script is tracked, its `PreToolUse` matcher is not, so a fresh clone and every `git worktree add` +> come up without it. +> +> **`scripts/docs/link_check.py` could never have caught this, which is the part worth keeping.** +> `.claude/` sits in that script's `WITHHELD` tuple, and the exemption `continue`s **before** `checked +> += 1`. Measured 2026-08-10 by planting two hrefs in a tracked document: a missing **non-withheld** +> path took the run to `FAIL: 1 unresolved` and the link total from 5359 to 5360; the same missing path +> under `.claude/` left the run `OK` **and the total unchanged at 5359** — the href was not merely +> resolved, it was never counted. A green link gate is not evidence about this class. (The same trap is +> already recorded from the other side in `tests/test_link_resolution.py`, whose first repo-wide +> measurement undercounted by 7 because `.claude/` was *present* in a long-lived local checkout.) +> +> **Residual, not fixed here:** the stale `.gitignore` comment. `.gitignore:84` still reads +> `# Claude Code: settings.json is shared/tracked; settings.local.json is machine-local (never commit)`, +> contradicted by `/.claude/` at `:142`. It is a comment with no mechanical effect, and `.gitignore` is +> outside this lane's file list, so it is carried to the owner rather than edited: replace "settings.json +> is shared/tracked" with a note that the whole `/.claude/` tree is ignored by the private-paths block +> below. + +**Related:** `.gitignore` (lines 128-146, 160), `scripts/security/scan_forbidden.py`, `scripts/dev/setup-leak-gate.ps1`, `.pre-commit-config.yaml`, `.github/workflows/ci.yml` (`noncode` at :472, pytest gate at :226), `tests/test_release_pipeline.py`, `tests/test_feature_map_claims.py`, `tests/test_scaffold.py`, `scripts/hooks/block-blanket-git-stage.ps1`, [`docs/SECURITY-DOCS-POLICY.md`](../../SECURITY-DOCS-POLICY.md), [`docs/SESSION-DRIFT-CONTROLS.md`](../../SESSION-DRIFT-CONTROLS.md), #321, #322. + +**Source:** public-repo disclosure audit, 2026-08-01. Verified against HEAD `12efbffc`; the audit flagged the finding as unconfirmed, and the absence of any such test/hook/gate is confirmed here. + +--- + +--- + +--- + +## 329. Five `MEFOR_ALLOW_INSECURE_TLS` cells bypass the ADR 0092 clamp + +> ✅ **SHIPPED 2026-08-06 — the four out-of-gate insecure-TLS cells now route through the ADR-0092 clamp.** Value **6/10** · Difficulty **4/10** · _quick win_. LDAPS (`auth/ldap.py`), the SFTP host key (`transports/remotefile.py`), the webhook sink (`pipeline/alert_sinks.py`) and the AI-broker (`transports/ai_broker.py`) now gate the `MEFOR_ALLOW_INSECURE_TLS` escape through `weakened_tls_escape_permitted[_here]` — the instance posture threaded into `AuthService` / `create_app`'s out-of-gate constructors — so on an enforcing production-PHI instance the escape is inert and an unverified/cleartext hop stays refused. The fifth cell the heading names (Direct SMTP) was already clamped in #323, so this converted the remaining four. + +> ⚠️ **AMENDED 2026-08-03 — the census is FOUR, not five: #323 landed and took the Direct SMTP cell.** The heading, the evidence table (*"Confirmed at HEAD"*) and Proposed §1 all still name `transports/direct.py:170` as an unclamped cell, but that file now holds **no call to the raw predicate at all** — it imports only `weakened_tls_escape_permitted_here` (`messagefoundry/transports/direct.py:63`) and gates both arms on it (`:197` cleartext SMTP, `:215` `tls_verify=false`), with the #323 rationale — including its own warning that this absence is scoped to that file and never repo-wide — at `:182-196`; `:170` is now unrelated cert-loading. The Scope note called this in future tense and the 2026-08-03 banner already enumerates only four cells while still calling them *"five per-site facts"*, so read the table as **at least four** sites still reading the unclamped `insecure_tls_allowed()`: the SFTP host key (`messagefoundry/transports/remotefile.py:375`, feeding `AutoAddPolicy`/`RejectPolicy` at `:392-394`), LDAPS (`messagefoundry/auth/ldap.py:113`), the webhook alert sink (`messagefoundry/pipeline/alert_sinks.py:291` — the item cites `:290`) and the AI broker (`messagefoundry/transports/ai_broker.py:140`). +> +> ⚠️ **Do not read the banner's "the cheap in-gate half shipped with #323" as discharging Proposed §1.** #323 took `direct.py`; it did **not** take `remotefile.py:375`, the other in-gate cell §1 names, which still reads the raw predicate. §1 therefore shrinks to a single one-line swap rather than closing, and §2's out-of-gate work is entirely untouched — LDAPS, the webhook sink and the AI broker are all still raw, including the LDAPS cell this item ranks first. + + +**Cluster:** Security / TLS posture. **Priority:** P2. **Verdict:** build. **Severity:** medium. + +**What:** ADR 0092 decision 2 introduced `weakened_tls_escape_permitted(posture)` so the blunt global `MEFOR_ALLOW_INSECURE_TLS` can never relax a hop on an enforcing PHI instance (`config/settings.py:226-230` — *"if not insecure_tls_allowed(): return False … return not (posture.enforcing and posture.is_phi)"*). Five cells never adopted it and still call the raw predicate. Confirmed at HEAD: + +| Cell | Site | What the env var buys | +| --- | --- | --- | +| SFTP host key | `transports/remotefile.py:375` | `self._accept_unknown = insecure_tls_allowed()` → paramiko `AutoAddPolicy` instead of `RejectPolicy` (`:392-394`) | +| Direct (S/MIME) SMTP | `transports/direct.py:170` | `if not insecure_tls_allowed():` → cleartext SMTP submission | +| LDAPS | `auth/ldap.py:113` | `if not insecure_tls_allowed():` → `ad_tls_verify=false`, i.e. `ssl.CERT_NONE` on the bind (`:131`) | +| Webhook alert sink | `pipeline/alert_sinks.py:290` | `if scheme == "http" and not insecure_tls_allowed():` → cleartext alert POST | +| AI broker | `transports/ai_broker.py:140` | `if scheme == "http" and not insecure_tls_allowed():` → the `[ai].api_key` credential on cleartext http | + +The inconsistency is sharpest *within a single file*. `transports/remotefile.py` decides three escape questions: FTPS `tls_verify=false` at `:176` and credentialed plain-ftp at `:577` both go through `weakened_tls_escape_permitted_here()`; the unknown-host-key question three hundred lines away does not. Likewise `transports/email.py:134` gates cleartext SMTP on `weakened_tls_escape_permitted_here() or config.tls_hop_attested or config.cleartext_accepted`, while its near-identical Direct sibling at `direct.py:170` consults nothing but the env var. + +This is an omission, not a recorded decision. [ADR 0092](../../adr/0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md):14-19 lists six non-connection cells the escape "survives for" — engine→store TLS, LDAPS, the webhook alert sink, the AI broker, the `[logging]` forwarder, the API PHI-read serve hop — but *survives* is a statement about the variable, not about the clamp: three of those six are clamped in code (store via `store/sqlserver.py:1498`, the forwarder and the PHI-read hop via `hop_insecure_escape_downgrades`) and three are not. And `config/settings.py:203-207`, which enumerates the surviving clamped cells, names only the forwarder and the PHI-read hop — LDAPS, the webhook sink and the AI broker appear in no list at all. + +**Not** part of this: `transports/database.py:110-113` reads `insecure_tls_allowed()` only on the `posture is None` arm and `hop_insecure_escape_downgrades(...)` otherwise. That is the documented unstamped fallback (`config/settings.py:228-229`), same as the store's, and was examined and excluded. + +**Why:** the clamp exists to contain an *operator mistake*, and that is the whole of the blast radius here. `MEFOR_ALLOW_INSECURE_TLS` is not attacker-influenceable — setting it on a Windows service means editing the NSSM service definition or machine environment, which needs Administrator, and an Administrator can already do strictly worse (the config dir is executed as the service account; `config/settings.py:247-256`). Nobody reaches these cells over the network. So this is **not** a remotely exploitable vulnerability and should not be described as one. + +What it *is*: the realistic failure is a dev/CI environment variable riding into a production service definition — the exact scenario ADR 0092 decision 2 was written for, and the reason the store, MLLP, FTPS and plain-ftp cells were converted. With it set, an enforcing production-PHI instance silently accepts an unknown SSH host key (trust-on-first-use against a MITM on a PHI file feed), disables LDAPS certificate validation on the service-account and user binds, and puts the `[ai].api_key` on the wire. The LDAPS case is the one worth ranking first: it is instance-wide rather than per-connection, it is the authentication substrate for every AD identity, and `auth/ldap.py`'s own comment claims the refusal means it "can no longer be silently turned on in production" — which is true of the refusal but not of the clamp. + +The AI-broker cell has an additional argument: `transports/smart.py:126-149` moved the *same* question — a credential on a cleartext token endpoint — off the raw escape and onto `refuse_cleartext_credential_hop` in commit `a3015196`, with a comment describing exactly this defect (*"It used to read the raw, UNCLAMPED `MEFOR_ALLOW_INSECURE_TLS`"*). `ai_broker.py:140` is the un-migrated twin of a cell fixed days ago. + +**Additionally — converting all five is what makes the property *checkable*, not just true.** While these five remain, "no unclamped escape survives on an enforcing PHI posture" is five separate per-site facts, each verifiable only by opening the site and reading it, and each silently falsified by a sixth cell added later. Convert them all and it collapses into **one repo-wide invariant**: the raw `insecure_tls_allowed()` becomes unreachable outside `config/settings.py`'s own clamp, so the absence of the raw predicate is checkable everywhere at once, with `weakened_tls_escape_permitted_here` as the thing that must still be present. Today the property is a convention enforced by review; afterwards it is an invariant enforced by a grep — and a *new* unclamped cell fails immediately instead of waiting for the next audit to enumerate it. + +That distinction matters concretely for the ASVS record. The scorecard's absence-claim mechanism runs regexes over the whole `*.py` corpus and **cannot scope a grep to one file**, so a per-connector claim ("`direct.py`'s escape is clamped") is not expressible and has to be carried as stated-but-unchecked prose. A repo-wide claim is expressible and machine-verified on every commit. So this item is not only five leaks to plug: it is the difference between a security property that must be re-audited by hand and one that a gate can hold. *(Framing contributed by the ADR 0156 ASVS-sweep session, 2026-08-02.)* + +**Scope note, because the count is moving and two censuses will disagree.** #323 routes `transports/direct.py` and `transports/email.py` through the clamp, taking the remaining set to four once it lands — so a census taken on that branch disagrees with one taken on `main`, and neither is wrong. Measured at `main` by counting **`ast.Call` nodes**, not matching lines: six real call sites outside `config/settings.py` — `auth/ldap.py`, `pipeline/alert_sinks.py`, `transports/ai_broker.py`, `transports/database.py`, `transports/direct.py`, `transports/remotefile.py`. `transports/database.py` is the documented unstamped fallback, excluded above; `transports/mllp.py` matches a naive grep for the raw name but its occurrence is **prose inside a docstring, not a call at all**. A line-based census reports it as a further site; an AST-based one does not — which is the instrument distinction, not a detail about this item. + +**Proposed:** convert all five, but note that a blanket swap to `weakened_tls_escape_permitted_here()` would silently fix only two of them. + +1. **In-gate cells — a one-line swap each.** `remotefile.py:375` and `direct.py:170` are built inside `build_check_registry`/`wiring_runner`'s `active_hop_posture` scope (`config/tls_policy.py:587-603`; the stamping sites are all in `pipeline/wiring_runner.py`), so `weakened_tls_escape_permitted_here()` reads a real posture there — byte-identical to how `remotefile.py:176`/`:577` and `email.py:134` already behave. +2. **Out-of-gate cells — thread an explicit posture.** `auth/ldap.py`, `pipeline/alert_sinks.py` and `transports/ai_broker.py` are constructed from `create_app`/`AuthService` (`auth/service.py:275`, `api/app.py:5335`), which never stamp the contextvar; `current_hop_posture()` returns `None` there and `weakened_tls_escape_permitted(None)` returns `True` (`config/settings.py:228-229`), so `_here()` would be **inert** — the fix would ship green and change nothing for LDAPS, the highest-value cell. Pass a posture explicitly, as the store does at `store/sqlserver.py:1498`. `create_app` already derives one at `api/app.py:1174-1179` (`_phi_read_posture = hop_posture_from_ai(ai_settings, enforcement=…)`) — thread that into the three constructors rather than deriving a fourth. +3. **Prefer the credential authority for `ai_broker.py:140`** — `refuse_cleartext_credential_hop` (`transports/rest.py:478-515`), matching the SMART fix, since it fail-closes on an unstamped posture (`rest.py:292-300`) and gives the same error contract. +4. **Consider whether the SFTP host-key cell belongs on this env var at all.** It is an SSH TOFU decision, not TLS; a dedicated per-connection `known_hosts` requirement (or a `host_key_accepted` declaration in the ADR 0153 idiom) would express it better than a global TLS switch. Filing the swap does not settle that; call it out in the fix PR. +5. Update the `docs/DEPLOYMENT.md`:408-418 bullet list (three *(Not clamped)* / *(raw escape)* annotations become *(Clamped)*) and the `config/settings.py:200-208` surviving-cells docstring in the same commit, and add regression tests asserting each cell refuses under `enforcement=enforce` + PHI **with the escape set** — the assertion that does not exist today for any of the five. + +**Related:** [ADR 0092](../../adr/0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md) decision 2 (+ its ADR 0153 amendment banner), [ADR 0153](../../adr/0153-collapse-the-posture-gradient-no-data-label-may-allow-a-cleartext-hop.md) decision 5, [ADR 0148](../../adr/0148-phi-default-posture-and-an-explicit-security-enforcement-level.md); `messagefoundry/config/settings.py` (`insecure_tls_allowed` / `weakened_tls_escape_permitted` / `_here`), `messagefoundry/config/tls_policy.py`, `messagefoundry/transports/remotefile.py`, `messagefoundry/transports/direct.py`, `messagefoundry/transports/email.py`, `messagefoundry/auth/ldap.py`, `messagefoundry/pipeline/alert_sinks.py`, `messagefoundry/transports/ai_broker.py`, `messagefoundry/transports/smart.py` (the shipped precedent, `a3015196`); [`docs/DEPLOYMENT.md`](../../DEPLOYMENT.md) §*The `MEFOR_ALLOW_INSECURE_TLS` escape hatch*, [`docs/SECURITY-LOOSENING.md`](../../SECURITY-LOOSENING.md); tests `tests/test_asvs_phase0.py`, `tests/test_remotefile_transport.py`, `tests/test_direct_transport.py`, `tests/test_email_destination.py`, `tests/test_hop_refusal_residuals.py`; #200 (closed — it built the clamp for the store/MLLP/FTPS/plain-ftp cells but never enumerated these five); the SMTP-unverified-TLS item from this same audit (`transports/direct.py` appears in both, at different lines and with a different fix). + +**Source:** public-repo disclosure audit, 2026-08-01. The audit classified the `docs/DEPLOYMENT.md` disclosure as honest and keep-as-is — the doc correctly names all five as unclamped; this item is the weakness the doc describes. + +--- + +--- + +--- + +## 331. Anonymizer's fail-closed leak-check has no structural PHI detectors + +> ✅ **SHIPPED 2026-08-06 — structural PHI-shape detectors + unmapped-field coverage report + token-floor signal built.** Value **6/10** · Difficulty **4/10** · _quick win_. `leak_check`/`leak_report` now run high-precision structural detectors (dashed SSN, punctuated NANP phone, CX `MR`/`MRN`-typed identifier) over **the fields no rule matched**, record every present-but-unmapped field in a coverage report (`LeakReport.unmapped_fields`, carried into the `LeakError` on a refusal and exposed via the `on_report` hook), and record `token_floor_failure()` in every report, folding it into the fail-closed decision under the `require_live_denylist` **opt-in** lever — default off, so a token-less CI/OSS/fork load still passes with the structural detectors as the live backstop; a deployment that must refuse on an unloaded denylist sets the lever. The whole structural block is mirrored byte-identical into `tee/anon/leak.py` with a new engine/tee `leak_report` parity test; each detector was falsified. ADR 0030 §5/§7/Consequences amended (the "deferred" phrasing was stale). The aggressive/broad-shape tier (bare-digit DOB/SSN, name-like runs) stays deferred by owner call — it mass-false-positives on HL7 bodies dense with dates/order-numbers. + +**Cluster:** Security & Compliance. **Priority:** P2. **Verdict:** build. **Severity:** medium. + +**What:** `anonymize_checked()` is the function that "earns the right" to write a de-identified dataset somewhere shareable, and its entire verification is one call ([`messagefoundry/anon/__init__.py:88-96`](../../../messagefoundry/anon/__init__.py)): + +```python +output = anonymize(raw, salt=salt, overlay=overlay, rules=rules) +hits = leak_check(output) +if hits: + raise LeakError(...) +``` + +`leak_check` ([`anon/leak.py:59-61`](../../../messagefoundry/anon/leak.py)) is `_scanner().scan_text(text, include_estate=True)` plus `message_has_site_code(text)`. `scan_text`'s full body ([`scripts/security/scan_forbidden.py:784-795`](../../../scripts/security/scan_forbidden.py)) is: the `FORBIDDEN` name patterns, one routable-`_IPV4` check, and the `ESTATE_TOKENS` substrings. There is no MRN-shape, SSN-shape, DOB-shape, phone-shape or name detector anywhere on this path. The module's other structural detectors, `_WORKTREE_SLUG` (:92) and `_HOME_PATH` (:99), are called **only** from `scan_file` (:756, :758) and are unreachable from `leak_check` — so on the anonymizer path the live structural detector set is routable-IPv4 alone. + +Three of the four live detectors are token-sourced and load **empty** without a token file — the scanner "degrades to STRUCTURAL-ONLY (routable-IPv4 only)" (:23-25), and `message_has_site_code` is "Always False when no site-code prefix is configured" ([`anon/surrogates.py:335-341`](../../../messagefoundry/anon/surrogates.py)). The fail-closed floor that exists for exactly this case, `token_floor_failure()` (:547), is consulted only inside `main()` (:881); the module-level `reload_tokens()` (:671) that the anonymizer's import path uses never checks it. So on a fork or a token-less checkout, `anonymize_checked` returns a green "leak-check passed" having verified that the HL7 body contains no routable IP address — and says nothing about it. + +**This was hit in practice.** De-identifying `samples/messages/hapi-hl7v2/batch_18_messages.txt` in `f3c6d348` required a hand-authored overlay for the fields the default map omits — GT1-8/16/17/18, IN1-4/5/6/7/11/18/44, OBR-35, and a non-standard DST segment (per that commit's own message). Those omissions are real at HEAD: `DEFAULT_RULES` ([`anon/rules.py:68-121`](../../../messagefoundry/anon/rules.py)) covers GT1-3/5/6/7/12, IN1-16/19/36/49 and OBR-16/32 and nothing else, and `git log -- messagefoundry/anon/rules.py` shows the file unchanged since the clean snapshot. Nothing flagged their absence — a human reading the corpus did. The overlay was never committed (`git show --stat f3c6d348` lists 7 files, no `anon.toml`), so the derived knowledge is gone and the next corpus starts from the same blind map. + +**Why:** the framework's promise is that a leak-check makes a dataset *proven* PHI-free before it may be committed or shared. What it actually proves is the absence of a **known string list**; a real MRN is not a denylisted string. The gap is honestly documented — [ADR 0030](../../adr/0030-anonymization-test-harness-tee.md):265-266 states it verbatim ("a field whose PHI the rule map **missed** sails through the fail-closed gate *clean*") and :268-270 / :339-343 defer structural detectors as a candidate improvement. This item is to build that deferral, not to report it. + +Bounded honestly: +- **This is not a runtime data-plane defect.** Nothing under `pipeline/`, `store/`, `api/` or `transports/` imports `anon` — the only production caller is `tee anonymize-captures` ([`tee/__main__.py:47,519`](../../../tee/__main__.py)), and the harness's `anonymizer=` hook is optional and unwired by default ([`harness/reconcile/capture.py:46,57`](../../../harness/reconcile/capture.py)). No attacker-reachable path exists; no inbound message triggers it. +- **Exploitation is not the failure mode.** Reaching this code means already holding real captures — i.e. someone legitimately handling PHI, who could mishandle it more directly. The risk is a *human* one: a green result reading as an assurance it does not carry, and a PHI-bearing corpus being committed on the strength of it. +- **The primary control genuinely is rule-map completeness**, and the ADR says so. This is a missing backstop, not a broken control. The residual is the ordinary case of a corpus using a field nobody thought to map — which is precisely what happened in `f3c6d348`. +- **Free-text is already handled**: OBX-5/NTE-3 default to a blunt full-redact (ADR 0030 §3), so the highest-risk residual is not this one. + +**Proposed:** +1. **Scope shape detection to what the anonymizer did not touch.** ADR 0030 (~:255) is right that a broad shape search over HL7 mass-false-positives — bodies are dense with 6-9 digit runs. But `anonymize` knows exactly which fields it rewrote, so run structural detectors **only over the fields no rule matched**. That makes SSN/NANP-phone/date/MRN shapes tractable without a false-positive storm. +2. **Add a cheaper coverage report first.** Have `anonymize_checked` surface every segment/field present in the input with no rule and no explicit keep-decision ("N unmapped fields: GT1-16, DST-4, …"). This alone would have caught the batch_18 case, needs no shape heuristics, and is a much smaller change than (1). +3. **Stop degrading silently.** Wire the existing `token_floor_failure()` (`scan_forbidden.py:547`) into the `leak_check` bridge so `anonymize_checked` refuses — or demands an explicit opt-out — when the token tables load empty, instead of returning clean. Have `LeakError`/the clean path name which detector tables were live. +4. **Land the batch_18 overlay** as a committed `anon.toml` fixture, or fold those fields into `DEFAULT_RULES`, so the hand-derived rule set is reusable rather than re-derived. +5. **Negative tests.** No test asserts the leak-check can see structural PHI, and none asserts behaviour on an empty token load — which is why the hole is invisible. Same lesson as #321: a green gate is evidence only once you have proved it can see that class. Mirror any change into `tee/anon/leak.py` (`test_anon_parity` pins the two). + +**Related:** [`messagefoundry/anon/leak.py`](../../../messagefoundry/anon/leak.py), [`messagefoundry/anon/__init__.py`](../../../messagefoundry/anon/__init__.py), [`messagefoundry/anon/rules.py`](../../../messagefoundry/anon/rules.py), [`tee/anon/leak.py`](../../../tee/anon/leak.py), [`scripts/security/scan_forbidden.py`](../../../scripts/security/scan_forbidden.py), [`tee/__main__.py`](../../../tee/__main__.py), `tests/test_anon_core.py`, `tests/test_anon_parity.py`, [ADR 0030](../../adr/0030-anonymization-test-harness-tee.md) §5 + Consequences (the deferral this item builds), #36 (shipped — its "Verifiability" bullet is the claim this narrows; closed, so not an amendment target), #321 (sibling: the publish-path token *source* is incomplete — a different mechanism; its Proposed §3 cross-references this gap, and its "#320-adjacent" phrasing there is a mis-reference, since #320 is the windows-2025 MLLP ingress item), and the case-blind `_HOME_PATH` item from this same audit (a third, disjoint leak-gate mechanism). + +**Source:** public-repo disclosure audit, 2026-08-01. + +--- + +--- + +--- + +## 337. handler-security lint: `getattr` indirection and the undecorated helper + +> ✅ **Done 2026-08-05 (#337).** Value **3/10** · Difficulty **3/10**. Both recall gaps in `_check_handler_security` (`checks.py`) are closed and pinned. A constant `getattr(mod, "name")` indirection now resolves in `_dotted_call_name`, so `getattr(os, "system")(...)` is flagged for `ambient-authority` (the shared resolver also flags a `getattr(time, "time")()` wall-clock read for `impure-transform`); and `phi-to-log` now scans undecorated `_*` transform helpers keyed on the first positional parameter, while `impure-transform` stays decorated-scope so the shipped `_pdf_mdm_transforms.py` ingest-time timestamp fallback stays clean. New `tests/test_checks_handler_security.py` cases cover both, and the change was proven green against `samples/config` before landing. Still an advisory-by-default filter — an evasion reaches neither the DEK nor the audit chain in either sandbox posture; ADR 0144 amended, to be re-scored upward when ADR 0147 (OS-level default-deny) lands. + +**Cluster:** Security & Compliance. **Priority:** P3. **Verdict:** build (small). **Severity:** low. + +**What:** Two execution-verified coverage holes in `_check_handler_security` ([`checks.py`](../../../messagefoundry/checks.py), ADR 0144), both still open at HEAD. + +*(1) `ambient-authority` sees only a literal name chain.* `_ambient_authority_hit` (`checks.py:690-721`) matches a bare `ast.Name` against `_AMBIENT_BARE_NAMES` — `frozenset({"eval", "exec", "compile", "__import__"})` at `checks.py:464` — then falls through to `_dotted_call_name`, which by its own docstring returns `None` "when it is not a pure Name/Attribute chain (e.g. the receiver is itself a call or subscript)" (`checks.py:549-560`). `getattr(os, "system")("…")` is precisely that shape: the outer call's `func` is an `ast.Call`, and the inner call's `func` is `Name("getattr")`, which is in no deny-list. Measured, in **strict** mode: + +```python +# /IB_T_handler.py +@handler("H") +def h(msg): + getattr(os, "system")("whoami") # line 7 — NOT flagged + globals()["__builtins__"]["eval"]("1") # line 8 — NOT flagged + mod = __import__("subprocess") # line 9 — flagged +``` +``` +_check_handler_security(cfg, strict=True) +# -> ok=False, required=True, "1 handler-security finding(s) … IB_T_handler.py:9 [ambient-authority]" +``` + +The opt-in Semgrep leg does not recover it either: `messagefoundry/security/semgrep/handler-security.yml:148-151` lists `eval(...)` / `exec(...)` / `compile(...)` / `__import__(...)` and no `getattr` pattern. ADR 0144:189-192 states this outright ("`getattr(os, "system")` remains a false-negative") — the defect is that nothing executable pins it, and the cheap resolution was never taken. + +*(2) `phi-to-log` is decorated-scope only, which excludes the documented transforms helper.* The rule loop is gated by `if _message_fn_decorator(node) is None: continue` (`checks.py:921-934`), `_body_calls` refuses to descend into nested defs (`checks.py:762-778`), and `_message_fn_decorator` is `FunctionDef`-only so an `async def` handler is out too (`checks.py:536`). Measured, in **strict** mode: + +```python +# /_feed_transforms.py (undecorated — the documented Hybrid helper) +def xform(msg): + log.info("transforming %s", msg.raw) +``` +``` +_check_handler_security(cfg, strict=True) -> ok=True, skipped=True, "no handler-security findings" +``` + +ADR 0144:193-195 records the decorated-scope trade, but justifies it with an **`impure-transform`** false positive ("the trade that keeps the shipped `_pdf_mdm_transforms.py` timestamp fallback clean") and then applies it to `phi-to-log` as well. `samples/config/_demo_oru_transforms.py` and `_pdf_mdm_transforms.py` exist, [`docs/CONNECTIONS.md`](../../CONNECTIONS.md) §"Decomposing by role" tells authors to put field-level transform logic there, and #226 is an estate-wide sweep to do exactly that — so the one CLAUDE.md §9 rule the lint encodes systematically skips the file the convention steers PHI handling into. + +**The third gap the audit named is narrower than described.** The non-recursive `base.glob("*.py")` at `checks.py:893`/`:898` (ADR 0144:196) is **not** an unscanned execution path. `load_config` globs `directory.glob("*.py")` non-recursively too (`config/wiring.py:3969`, and `:4392` for `validate_config`), and `_SiblingHelperFinder.find_spec` returns `None` for any dotted name and serves only `_`-prefixed top-level helpers from the config dir (`wiring.py:3902`, `:3908-3912`). A `.py` in a config subdirectory is therefore neither executed by the loader nor importable by a sibling — and `_assert_safe_config_source` is non-recursive for the same reason (`wiring.py:4194`, `:4324`). The lint's file set already equals the executable set. A recursive walk here would make the lint report on files the safe-source ownership gate never vets — an asymmetry in the other direction. #226 already parks recursion as a *loader* question; it belongs there, not here. + +**Why:** Bounded, and bounded hard. The lint is advisory by default (`checks.py:953`, `ok=not strict, required=strict`), so a finding blocks nobody unless an adopter opts into `--strict-handler-security` on their own CI. It governs code the adopter's own administrator authors, inside a directory whose write access is already the trust boundary (`_assert_safe_config_source`, `wiring.py:4194`/`:4324`) — anyone who can drop a `.py` there already has arbitrary in-process execution under the engine account, so this is **not** a privilege boundary and evading it buys an attacker nothing they did not already have. + +> ⚠️ **Rationale amended 2026-08-01 (ADR 0087 sandbox session) — the severity is right, the reason was not.** "The author already has in-process execution" is true at the **default** `[sandbox].mode=off`, and **false** under `mode=subprocess`, where the entire premise is that the author is *not* trusted with it. A severity floor resting on a posture-specific claim reads as settled and misleads the next reader. The rationale that holds in **both** postures: the lint is advisory and pre-deployment; under `mode=off` the author already has in-process execution, and under `mode=subprocess` an evasion still only reaches **host** actions the sandbox does not confine — `DEFAULT_FORBIDDEN_MODULES` (`pipeline/sandbox.py:84-95`) blocks `socket`, `ssl`, `asyncio`, `multiprocessing`, the I/O-bearing `messagefoundry.*` subpackages and `cryptography`, but **not `os` or `subprocess`** (verified at HEAD). ADR 0087 confines the **address space** (the child cannot reach the parent's DEK, audit chain or sockets), not the **host**; OS-level default-deny is ADR 0147, *Proposed with no code*. So an evasion reaches neither the DEK nor the audit chain in either posture. **Re-score upward when ADR 0147 lands**, at which point the lint becomes load-bearing for exactly the class OS confinement is meant to close. ADR 0144:171-174 and the `_check_handler_security` docstring (`checks.py:878`) both say so: "a filter, not a fix." There is no PHI-exposure path and no runtime behaviour change of any kind. + +What it *is*: an adopter who turns on the strict gate gets a **green build** on a Handler containing `getattr(os, "system")`, and gets a green build on a transforms helper logging `msg.raw` at INFO. Gap (2) is the one that actually costs something, because the miss is not a malicious bypass — it is the ordinary fallible-author case ADR 0144 exists for, landing in the exact file the project's own layout guidance created. Gap (1) is mostly a claim-hygiene problem: the ADR asserts the false negative in prose and no test proves it, so nobody notices if a future change silently widens or narrows it. + +**Proposed:** +1. **Resolve `getattr` on a known-dangerous root** in `_ambient_authority_hit` (`checks.py:690`): when the call's `func` is `getattr(, )`, splice the constant into the chain and re-run the existing predicate; when the second arg is **non-constant** on a root already in `_AMBIENT_ROOTS`/`_AMBIENT_OS_PATHS`, flag it directly (it is unresolvable statically, and that is the honest answer). ~15 lines, no new dependency, reuses `_dotted_call_name`. +2. **Widen `phi-to-log` past the decorated scope** — scan module-level functions in `_*.py` helpers (and nested defs inside a decorated body) for the same rule, keying on a parameter whose name matches the caller's message symbol or on `.raw`/subscript access. `impure-transform` stays decorated-scope: the ADR's stated FP rationale is specific to it, so widening only `phi-to-log` costs nothing against that rationale. Recalibrate against `samples/config/_demo_oru_transforms.py` + `_pdf_mdm_transforms.py` before landing. +3. **Pin both with tests** in `tests/test_checks_handler_security.py` — a positive for the `getattr` form and a positive for the undecorated-helper PHI log; today only the *negative* undecorated cases are pinned (`:205`, `:287`, `:298`), so the gaps are asserted in prose and nowhere in code. +4. **Update ADR 0144's residual list** (`:189-196`) as part of the same change: strike the getattr and decorated-scope-`phi-to-log` bullets when fixed, and rewrite the "Non-recursive" bullet to state *why* it is correct (the loader is non-recursive too) rather than listing it as a gap. +5. Optional: add a `getattr` pattern to `security/semgrep/handler-security.yml` for the opt-in taint leg, and fix the `_body_calls` docstring (`checks.py:763-766`), which claims "each nested def is scanned on its own iteration" — true only for a nested def that itself carries `@handler`/`@router`. + +**Related:** [`messagefoundry/checks.py`](../../../messagefoundry/checks.py) (`_ambient_authority_hit`, `_check_handler_security`, `_body_calls`, `_message_fn_decorator`), [`messagefoundry/security/semgrep/handler-security.yml`](../../../messagefoundry/security/semgrep/handler-security.yml), [`tests/test_checks_handler_security.py`](../../../tests/test_checks_handler_security.py), [ADR 0144](../../adr/0144-security-lint-gate-over-admin-authored-router-handler-config.md) (this lint), [ADR 0087](../../adr/0087-sandbox-subprocess-isolation.md) + #197 (the runtime half — SHIPPED), [`docs/ADOPTER-CI.md`](../../ADOPTER-CI.md) (the operator control listing, line 178), [`docs/CONNECTIONS.md`](../../CONNECTIONS.md) §"Decomposing by role", #226 (the Hybrid-layout sweep, and the loader-recursion question). + +**Source:** public-repo disclosure audit, 2026-08-01. ADR 0144 is honest and stays — the defect is what needs fixing. + +--- + +--- + +--- + +## 338. TLS key-exchange groups are inherited, not pinned + +> ✅ **SHIPPED 2026-08-06 (#338) — key-exchange groups documented as inherited, plus a report-only surfacing.** Value **3/10** · Difficulty **2/10**. `harden_kex_groups` pins nothing until `SSLContext.set_groups` lands in **Python 3.15**, so every built context inherits OpenSSL's default group list — forward-secret but wider than the approved pin — which makes this documentation accuracy plus observability, changing no live TLS behaviour. The three restatements that still read as *pinned* are corrected to say *inherited*: `CONTAINER-EXPOSURE-EVALUATION.md` and `ASVS-L2-PHASE0-CHANGES.md`, plus #200's Closes line in `docs/archive/backlog/BACKLOG-CLOSED.md` (11.6.2 annotated PARTIAL, see PHI.md §4). Added an additive report-only `kex_groups` field on `SecurityPosture` beside `fips_attestation()`, rendered on the console status page behind engine seam v18. The two Python-3.15 tripwire tests are left in place as the trigger to set the pin. + +**Cluster:** Security & Compliance. **Priority:** P3. **Verdict:** build. **Severity:** low. + +**What:** [`config/tls_policy.py`](../../../messagefoundry/config/tls_policy.py):150-152 returns without pinning whenever the API is absent — + +```python +set_groups = getattr(ctx, "set_groups", None) +if set_groups is None: + return None +``` + +`SSLContext.set_groups` is a **Python 3.15** addition, so on this tree (3.14.6 / OpenSSL 3.5.7) `hasattr(ctx, "set_groups")` is `False` and `APPROVED_KEX_GROUPS` (`tls_policy.py`:89) reaches **zero** of its six call sites — [`api/tls.py`](../../../messagefoundry/api/tls.py):55, [`transports/mllp.py`](../../../messagefoundry/transports/mllp.py):543 and :582, [`transports/dicom.py`](../../../messagefoundry/transports/dicom.py):145 and :463, [`transports/remotefile.py`](../../../messagefoundry/transports/remotefile.py):213. Every built context inherits OpenSSL's default group list. Re-measured 2026-08-01 against the real `build_api_ssl_context`, at both `tls_min_version` 1.2 and 1.3 (identical results): + +``` +approved = {'X25519': True, 'secp384r1': True, 'prime256v1': True} +non_approved = {'ffdhe2048': True, 'ffdhe3072': True, 'secp521r1': True, + 'secp224r1': False, 'sect571r1': False} +``` + +**The code and the primary docs are already honest about this.** The 2026-07-29 correction sweep fixed the docstrings (`tls_policy.py`:11-15, :114-148), [`PHI.md`](../../PHI.md):638 (now scored `[PARTIAL — … the group pin is INERT until Python 3.15]`) and :648-655, [`ASVS-L2-PHASE0-CHANGES.md`](../../ASVS-L2-PHASE0-CHANGES.md):230-231, and struck §4(b) of [ADR 0092](../../adr/0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md):170-172 with an amendment at :215-247. Three restatements survived it: + +1. [`CONTAINER-EXPOSURE-EVALUATION.md`](../../CONTAINER-EXPOSURE-EVALUATION.md):50 — under a heading that reads *"What is actually built (verification, not re-derivation)"*, the `build_api_ssl_context` row's **Confirmed behavior** cell says `optional ciphers, hardened KEX groups + strict X.509`, unqualified. This is the strongest surviving instance: the column asserts verification. +2. `BACKLOG.md`:6416 — #200's `**Closes (ASVS 5.0 L3):** 4.2.1, 4.4.1, 11.6.2, …` still claims 11.6.2 closed, while `PHI.md`:638 scores the same cell PARTIAL. #200's banner at :6412 carries the correction, so the item contradicts itself two lines later. +3. `ASVS-L2-PHASE0-CHANGES.md`:253 — the PQC migration row says *"add it to the pinned group/cipher policy"*, presupposing a pin. + +Separately: the docstring argues *"the return value is the point"*, but all six call sites discard it, so the report exists only in tests. `SecurityPosture` already carries a report-only read-out sourced from this same module (`api/app.py`:1528, `fips_attestation()`), and carries nothing for KEX groups. + +**Why:** the residual is **wider than policy, not weak**, and this item is documentation accuracy plus observability — not a transport weakness. Every group that gets in is forward-secret; `ffdhe2048`/`ffdhe3072`/`secp521r1` are the whole delta, and the genuinely weak `secp224r1` (112-bit) and `sect571r1` (binary-field) are refused. The forward-secrecy property ASVS 11.6.2's first clause is about comes from the enforced TLS 1.2+ floor, and `harden_cipher_suites` (`tls_policy.py`:334-364) **raises** on any non-forward-secret suite at every one of the same six sites — so nothing here admits static RSA/DH. There is no exploit path: an attacker cannot downgrade to anything the floor does not already permit; the only reachable effect is a *client* choosing a still-forward-secret group outside the preferred three. It is immaterial on the default `127.0.0.1` bind, where no TLS is presented at all. The cost of leaving it is a reader of `CONTAINER-EXPOSURE-EVALUATION.md` §0 or of #200's Closes line concluding the pin is enforced and not looking again — which is exactly how the "3.13+" error survived three assessments. + +**Proposed:** +1. Correct `CONTAINER-EXPOSURE-EVALUATION.md`:50 to say the groups are **inherited** (attempted pin inert until Python 3.15) and point at `PHI.md` §4 rather than restating the measured set — per CLAUDE.md §11, state it once and link. +2. Reconcile the ledger: drop `11.6.2` from #200's Closes line at `BACKLOG.md`:6416, or annotate it to match `PHI.md`:638's PARTIAL score. Two ledger surfaces must not disagree on one ASVS cell. +3. Reword `ASVS-L2-PHASE0-CHANGES.md`:253 to "the approved group/cipher policy". +4. Consider an additive report-only `kex_groups: str | None` on `SecurityPosture` fed by the discarded `harden_kex_groups` return (same shape as `fips_mode`/`openssl_version`, `api/app.py`:1528) so the inertness is operator-visible, not test-only. Additive → a `_ui_seam` bump. +5. **Do not delete or relax the tripwires.** `tests/test_tls_policy.py`:117 asserts the `None` unconditionally and `tests/test_api_tls.py`:1278 measures the accepted-group set with an assertion at :1318 that a non-approved group *does* get in. Both go red the day an interpreter grows the API — that red **is** the "re-evaluate when 3.15 lands" trigger, so no dated review is needed. Their failure messages already name the docs to re-derive. +6. **Do not** substitute `set_ecdh_curve`. It takes exactly one OpenSSL curve short name, so pinning through it would refuse two of the three approved groups (`tls_policy.py`:139-148 records the trap, including that `secp256r1` is a valid group-list alias but not a valid curve name — the curve spelling is `prime256v1`). + +**Related:** [`config/tls_policy.py`](../../../messagefoundry/config/tls_policy.py) `harden_kex_groups` / `APPROVED_KEX_GROUPS` / `harden_cipher_suites`; the six call sites listed above; [ADR 0092](../../adr/0092-posture-keyed-transport-hop-refusal-refuse-the-insecure-phi-hop.md) 2026-07-29 amendment; [`PHI.md`](../../PHI.md) §4; [`ASVS-L2-PHASE0-CHANGES.md`](../../ASVS-L2-PHASE0-CHANGES.md) §*TLS key-exchange & cipher posture*; [`Secure_Development_Standards`](../../Secure_Development_Standards.md) §3 (this defect is its worked example); `tests/test_tls_policy.py`, `tests/test_api_tls.py`; #200 (closed — its Closes line is fix (2) above; amending a closed item's prose is fine, but it must not gain an OPEN banner). + +**Source:** public-repo disclosure audit, 2026-08-01. Re-verified and re-measured at HEAD on the same date. + +--- + +--- + +## 342. Sandbox worker kill does not reap a grandchild holding the response pipe + +> ✅ **BUILT 2026-08-06 (local commit on fix-342-sandbox-reap; owner opens the PR).** Value **5/10** · Difficulty **6/10** · _money pit_. `SandboxSession._kill` now reaps the whole worker process tree — a Windows `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` job object the worker is assigned to before its boot frame, and a POSIX new-session process group killed with `killpg` (`start_new_session=True`) — so a grandchild the Handler spawned can no longer inherit fd 1 (the response pipe) and outlive the kill as a leaked orphan writing onto a pipe the parent believes belongs to a fresh worker. Best-effort process hygiene, not the trust control (ADR 0087's codec + per-dispatch id + unsolicited-frame check keep a stray grandchild frame harmless): a job-assign failure degrades to a single-process kill, logged. The reap logic lives in `pipeline/sandbox.py`; the `_sandbox_codec.py` and `docs/CONFIGURATION.md` prose was synced to match. The ADR 0087 / ADR 0147 residual co-design (and the vault threat-model note) is left to the owner — reported, not done here. + +**Cluster:** Security & Compliance. **Priority:** P2. **Verdict:** build (small). **Severity:** medium, low (likelihood: requires Handler-authoring rights, i.e. the same admin threat model as #339). + +**Bounded by the #339 correlation fix, not closed by it:** a grandchild cannot make the parent accept a *forged answer* — the per-dispatch `secrets.token_hex(16)` id is unguessable and the unsolicited-frame check is fatal to the worker. So the residual is **availability and process hygiene**, not misdelivery: the orphan can force repeated kill+respawn cycles on its own feed (each dead-lettering the message in hand, fail-closed) and accumulate leaked processes. + +**Fix direction:** spawn into a job object on Windows (`CREATE_NEW_PROCESS_GROUP` + a kill-on-close job) and a process group on POSIX (`start_new_session=True`, then `killpg`), so the whole tree dies with the worker. Note the platform asymmetry is the same one ADR 0147 already documents for confinement, so the two should be designed together rather than twice. + +**Related:** #339, ADR 0087 (residual now stated there), ADR 0147 (OS-level confinement — the natural home for the job-object work), #343 (the sibling fd-2 issue). + +**Source:** adversarial review of the ADR 0087 sandbox codec, 2026-08-01. + +--- + +--- + +## 346. The sandbox import boundary is enforced only at runtime, under an off-by-default flag + +> ✅ **SHIPPED 2026-08-06 — a static `ast` import-boundary guard now pins it.** Value **4/10** · Difficulty **3/10** · _fill-in_. [tests/test_sandbox_import_boundary.py](../../../tests/test_sandbox_import_boundary.py) walks the `ast` import nodes of `_sandbox_codec.py` and `_sandbox_worker.py` and asserts none resolves under a `DEFAULT_FORBIDDEN_MODULES` prefix (imported from the runtime constant, never copied), with a committed positive control that each static import form the walker handles is seen and a negative control that benign `messagefoundry.*` imports are not flagged. Both files are clean today; the guard would red on first deployment if a future edit reintroduced a forbidden import, instead of failing silently only under `[sandbox].mode=subprocess`. + +**Cluster:** Correctness / test coverage. **Priority:** P2. **Verdict:** build (small). **Severity:** medium (blast radius: a feature is DOA for everyone who opted in), medium (likelihood: the codec's constructor set is precisely the surface that grows as the payload model does). + +**Why it fails selectively — the reason this wants a test and not a comment.** The population that could report the breakage is the population *not* running the default. A future violation yields a green CI suite, a byte-identical `mode=off`, and a hard failure **only** on installs that turned the sandbox on for security reasons. The failure mode is inverted: the more security-conscious the deployment, the worse its experience, and the quieter the signal reaching the maintainer. + +**Measured, not assumed (2026-08-02):** `git grep -l "FORBIDDEN_MODULES" -- tests/` returns nothing — no test references the constant in any form. [`_sandbox_codec.py`](../../../messagefoundry/pipeline/_sandbox_codec.py) imports exactly the types the two ends construct (`CodeSet`/`UnmappedKind`/`UnmappedPolicy`, `ContentType`, `CapturedResponse`, `RunContext`, `Send`/`SetMeta`/`SetState`/`WiringError`, `Message`/`RawMessage`), today all under `config/` and `parsing/` — so the invariant **currently holds**. This item is about keeping it that way, not repairing it. + +**Fix direction.** A static test that walks the imports of `_sandbox_codec.py` and `_sandbox_worker.py` (stdlib `ast`, transitively across first-party modules) and asserts none resolves under a `DEFAULT_FORBIDDEN_MODULES` prefix. Anchor it on the **constant**, never a copied list — two copies of a rule drift, and the copy that drifts is the one nobody is testing. + +**Measurement discipline — the part that decides whether this is worth building.** The test must be demonstrated to **fail** against a deliberately introduced forbidden import *before* it is trusted. An import-walker that silently resolves nothing passes for exactly the same reason a correct one does, so a green run proves neither. Have it report what it walked, not merely that it walked. + +**Related:** #339 (surfaced it; relocated `CapturedResponse`), ADR 0087 (the boundary), ADR 0013 (the loopback re-ingress that was DOA), #342 / #343 (the other two findings the #339 review filed but did not fix). + +**Source:** adversarial review of the ADR 0087 sandbox codec, 2026-08-01; the `CapturedResponse` violation is measured, not hypothetical. + +--- + +--- + +## 1006. A mutation that matches is not a mutation that bites: the absence-claim gate proves syntax, never behaviour + +> ✅ **SHIPPED 2026-08-06 — a new opt-in mode can prove an absence claim BITES, which the pattern +> check structurally cannot.** Value **6/10** · Difficulty **3/10** · _quick win_. +> `scripts/asvs/scorecard.py` gains a `--prove-absences` mode: per claim it applies the `mutation` to +> a scratch copy of the tree and requires a named `observable` (a pytest node id) to go RED, failing +> closed on any exit code that is not an honest test failure (an already-red baseline, an +> uncollectable node, or a mutation that only breaks import is a PROVE-ERROR, never a proof). So a +> well-formed reintroduction that would change nothing if applied CAN be caught the moment its claim +> carries an `observable` — but the default `verify` path is byte-unchanged and no authored claim +> carries one yet, so nothing new is blocked by this alone today. Two optional `Absence` fields +> (`mutation_path`, `observable`) feed it, a coarse same-file static backstop screens claims that +> carry no observable, the scratch copy refuses secrets / the store / `docs/security` (defence for the +> eventual vault run), and fixture negative controls plus a CLI exit-code test prove the mode itself +> can go red. Public repo script + fixtures only; wiring the mode over the vault's ~81 existing +> absence claims (untouched) and backfilling their observables is the owner's follow-up +> (`scorecard.py:14-16`, ADR 0156 §7). + +**Cluster:** Security & Compliance. **Priority:** P2. **Verdict:** build. **Severity:** medium — +the defect is in the instrument, not the engine, and a green instrument that cannot go red is the +class [ADR +0158](../../adr/0158-silent-controls-green-signals-that-mean-nothing-and-shape-over-detection.md) exists +to name. + +**What.** `check_absences` ([`scripts/asvs/scorecard.py`](../../../scripts/asvs/scorecard.py):387, +called from `verify` at `:495`) admits an *absence claim* — a scorecard assertion that some thing +is **not** in the corpus — and rejects it three ways: + +| Mode | Line | The question it actually asks | +|---|--:|---| +| INERT | `:395` | does `a.pattern` match `a.mutation`? | +| BLIND | `:401` | does `a.positive_control` still match the Python corpus? | +| FALSE | `:408` | does `a.pattern` match the Python corpus? | + +`:395` is `re.search(a.pattern, a.mutation)`. `mutation` is a plain `str` field of the same TOML +row (`Absence`, `:107-109`); the corpus is never consulted for it and it is **never applied to +anything**. So a claim whose `mutation` is a syntactically perfect, honestly-authored +reintroduction that *would change nothing observable if written into the code* passes all three +tests, is recorded as a verified absence, and is counted in the "verified N absence claims" line +at `:625`. + +There is a fourth failure mode and the gate has no name for it: **the mutation is well-formed, the +pattern fires on it, the control speaks, the corpus is quiet — and applying the mutation changes +nothing.** + +**Why — the worked instance, and why it generalises.** The claim is **ASVS cell 13.3.4's absence +claim**, which lives in the vault-only `docs/security/asvs-scorecard.toml` (`docs/security/` is +gitignored in this public repo — `git ls-tree -r origin/main -- docs/security` returns nothing, so +a session reading this here cannot open it; it is in the **MessageFoundry vault repository**). Its +mutation inserted a `raise` inside `_maybe_escalate_dek` +(`messagefoundry/pipeline/secret_rotation.py:319`, under the guard at `:341`, called from +`reconcile_rotation_meta` at `:309`). + +That exception has **exactly one destination in the engine**: `reconcile_rotation_meta` is awaited +at `messagefoundry/pipeline/engine.py:1051`, inside a `try:` opened at `:1050` whose `except +Exception:` at `:1062` has a body of one `log.exception(...)` call (`:1065-1067`). Applying the +mutation verbatim yields a logged traceback, a normal engine start, and an absence-claim regex +that now matches. The instrument would have gone from green to green. + +**`reconcile_rotation_meta` is ALSO awaited directly by three tests** — +`tests/test_secret_rotation_watcher.py:107`, `:423`, `:435` — where the raise propagates uncaught. +That distinction is load-bearing for the proposal below: it is the difference between *"no +observable exists for this mutation"* and *"an observable exists and the gate never names it."* +Step 1's design turns on which is true, so establish it before writing the field. The engine +destination is singular; the test call sites are not. + +**The handler at `:1062` is not the defect and must not be "fixed" by this item.** Its purpose +is correct and is written down at `:1063-1064` — *"A reconcile failure must never take the engine +down … Logged, not raised."* The defect is that nothing in the instrument asks where a mutation's +effect lands. + +It generalises because nothing about the mechanism was special. A mutation that raises into a +swallow, writes a field nobody reads, sets a flag nobody branches on, or edits a docstring +satisfies `:395` exactly as well as a real one. The instance is closed; the property that let it +through is not, and that property covers every absence claim already authored and every one +authored next. + +**The instance's replacement is itself unproven.** That mutation has been re-sited outside the +handler on the record side — the re-siting the DEK calendar-expiry item filed in this batch treats +as its implementation sketch — but **the replacement has not been proved by execution either**, +which is the whole point of this item. + +**Nearest existing mechanism.** Two, and both are the seam this extends rather than a substitute +for it. + +- The loader **already refuses** an absence claim carrying no `mutation` at all (`:236-244`) — so + "a required field, enforced at load, with a message telling the author what to write" is a shape + this file already has and can be copied rather than invented. +- The `Absence` docstring (`:88-104`) already anticipates **one** vacuity mode and closes it in + prose: *"Do NOT derive `mutation` from `pattern`. A value generated from the thing it validates + satisfies the check by construction, which would make this the most authoritative-looking + vacuous gate in the file."* That is the right instinct aimed at a different mode — it guards a + mutation *dishonestly* constructed. This item is about one constructed honestly and still not a + control. + +**Proposed.** + +1. **A required `observable` per absence claim** — the named artifact that goes red when the + mutation is applied: a `tests/test_x.py::test_y` node id, or a documented startup/handshake + refusal. Refuse to load a claim without one, reusing the `:236-244` refusal shape and its + message style. +2. **Prove it by execution, at least once per claim.** A `--prove-absences` mode that, per claim, + applies the mutation to a scratch tree, runs the named observable, requires it to **fail**, and + reverts. Without this, step 1 adds a *name* for a control rather than a control — and #1000 + states the standing rule in one sentence: a green run is evidence only once the gate has been + shown it can go red on that class. +3. **A cheap static backstop for the mode actually found**, filed honestly as a heuristic: flag a + mutation whose landing site is lexically inside a `try:` whose handler is a bare `except + Exception:` with a log-only body. It would have caught this instance. It proves nothing in + general and must not be written up as if it does. +4. **Negative controls for each new mode**, beside the existing per-mode tests in + `tests/test_asvs_scorecard.py` (`:233` INERT-on-prose, `:260` INERT-decided-before-the-corpus, + `:195` BLIND, `:214` FALSE). The file already has the pattern; match it. + +**The trap this fix must not walk into.** An `observable` field that is recorded and never +executed is the same defect one level further out — a field validated for *shape* while the +property goes unmeasured, which is precisely what `:395` already does to `mutation`. If only one +of steps 1 and 2 can be built, build **2**: an executed proof with no schema field is worth more +than a schema field with no proof. + +**Step 2 is the item; steps 1, 3 and 4 are its trim.** A mutation-testing harness — scratch-tree +management, subprocess test invocation, red-assertion, rollback — is materially larger than the +other three combined. Split, step 2 alone prices at 4 and the rest at 2; the filed **3** is the +honest blend of the two, and an implementer who builds only step 1 has not built this item. + +**Scope note, and the cost deliberately excluded from the difficulty.** The public repo holds the +script and its fixture tests; the real posture data lives in the **vault repository** and this item +does not touch it (`scorecard.py:14-16`, ADR 0156 §7). Landing steps 1–2 **invalidates every +absence claim already authored** — **81 cells carry one** — until each is given an observable. +That re-authoring is the real schedule cost, is named here deliberately, and is **not** priced +into the difficulty number, which prices only the `ruff` + `mypy --strict` + `pytest` remainder in +the public repo. **Restate that exclusion in the PR body**, or a reader who sees difficulty 3 and +then discovers 81 claims need observables will believe the estimate lied. + +**Trigger:** none — it has fired. The instance was found by hand, by executing a mutation the gate +had already passed. + +**Related:** #1000 (prove each required merge context can fail — the same property one level down, +on CI gates rather than a compliance instrument); #353 (a compliance artifact nothing compares to +the record); #347, archived (an assertion that passes for a reason unrelated to the property it +claims to test); the DEK calendar-expiry item in this batch (whose design borrows the re-sited +mutation this item says is still unproven); ADR 0158 (the defect class); ADR 0156 (scorecard as +data — the ADR that introduced `Absence`). + +**Source:** an ASVS build-or-accept costing pass, 2026-08-03. The instance's own mutation has been +replaced on the record side; this item is the class it exposed, not the instance. +`check_absences`, the `Absence` dataclass and the loader refusal were read at `origin/main` +`88703a3a` for this filing, as were the swallow at `engine.py:1050-1067`, the mutation's landing +site at `secret_rotation.py:341`, and the three direct test call sites. + +--- + +## 1009. SOAP `body_secret_value_` is redacted, registered and documented — and never fingerprinted + +> ✅ **Built 2026-08-05 — Scored 2026-08-04, P2.** Value **5/10** · Difficulty +> **2/10**. `connector_secret_env_values`, the ASVS 13.3.4 runtime rotation fingerprinter, now +> filters connector secrets through `_is_secret_setting` (`config/wiring.py:725`) instead of bare +> `_SECRET_SETTING_KEYS` membership, so the prefix-only `body_secret_value_` SOAP body-secret +> class is fingerprinted and a rotation of it is auto-detected the way every sibling class is. The +> missing reverse gate (`test_registered_connector_secrets_are_reachable_by_the_fingerprinter`) now +> asserts every registered connector secret is reachable by the fingerprinter, so the "can never +> disagree" invariant is enforced rather than assumed and a future hand-added registry entry cannot +> slip through (ADR 0015). + +**Cluster:** Security & Compliance. **Priority:** P2. **Verdict:** build. **Severity:** low — a +monitoring gap on an opt-in connector secret class, **not** a disclosure. + +**The defect.** `connector_secret_env_values` +([`messagefoundry/config/wiring.py`](../../../messagefoundry/config/wiring.py):702) collects the +`env()`-sourced credential values the wired graph references; `pipeline/secret_rotation. +reconcile_rotation_meta` then keyed-MACs each with the DEK-derived MAC so a changed value +auto-detects a rotation. Its filter, at `:725`: + +```python +if name in _NON_ROTATABLE_SECRET_SETTING_KEYS or name not in _SECRET_SETTING_KEYS: + continue +``` + +`body_secret_value_` — emitted at `:2305` when a `Soap(body_secrets={token: env(...)})` map is +desugared to flat top-level settings — is **not** a member of `_SECRET_SETTING_KEYS` +(`:614-662`; grepped, zero hits). It is secret only via the prefix branch of `_is_secret_setting` +(`:686`): `return name in _SECRET_SETTING_KEYS or name.startswith("body_secret_value_")`. The +redaction path calls that helper. The fingerprint path does not. So the class is masked on +`/metadata` and in `graph --json`, registered as a critical secret, documented with a rotation +cadence — and invisible to the rotation watcher. + +**On "nothing is exposed" — the enumerable version.** No disclosure follows from this, because +both redaction consumers call `_is_secret_setting`, not the frozenset: `config/wiring.py:742` +(`is_secret = _is_secret_setting(name)`, the settings serializer) and +`config/connection_schema.py:107` (`"secret": _is_secret_setting(name)`, which is what +`connection schema --json` emits and what the VS Code form at `ide/src/connectionForm.ts:51` +consumes downstream). Those are the two, enumerated by `git grep -n "_is_secret_setting"` — **not +a closed-set claim about "every serializer surface,"** which no instrument in this filing +establishes. Re-run the grep rather than trusting the enumeration. + +**The fix is one line**, using the helper the module's own docstring (`:673-686`) names as the +single source of truth for both settings serializers: + +```python +if name in _NON_ROTATABLE_SECRET_SETTING_KEYS or not _is_secret_setting(name): + continue +``` + +`_is_secret_setting` is defined at `:672`, above the call site, and `body_secret_value_` is not +in `_NON_ROTATABLE_SECRET_SETTING_KEYS` (`:697`), so the change is additive: it enrols the class +and moves nothing else. The factory already forbids an inline literal, a `default=` and a `cast=` +on each body secret, so every one is a bare `EnvRef` that the `isinstance` check at `:727` +accepts. + +**Why it survived: the gate that should have caught it asserts the invariant it violates.** +`tests/test_secret_rotation_inventory.py:101` registers the class **by hand** — `"body_secret_ +value": "SOAP body_secret_value_ injected secrets (ADR 0015)"` — and +`test_registry_secrets_appear_in_rotation_schedule` (`:157`) requires it to carry a +rotation-schedule row. So the secret is inventoried and documented as rotatable. But +`test_secret_setting_keys_are_registered` (`:182`) enumerates **`_SECRET_SETTING_KEYS`** (`:204`: +`rotatable = set(_SECRET_SETTING_KEYS) - _NON_ROTATABLE_SECRET_SETTING_KEYS`) to find things that +must be registered — and `body_secret_value` entered `CRITICAL_SECRETS` without ever passing +through that set, so the gate cannot see the direction that is actually broken. Its own comment, +at `:188-189`, states the invariant that does not hold: the set is *"the single source of truth … +ALSO read by the ASVS-13.3.4 runtime fingerprinter `connector_secret_env_values`, so the +registration gate and the runtime rotation set can never disagree."* They disagree for exactly +this class, and that sentence is the reason nobody looked. + +**So the fix is two changes, not one.** The predicate at `:725`, **and the reverse assertion** — +every `CRITICAL_SECRETS` entry naming a connector setting must be reachable by +`connector_secret_env_values` — plus a regression test that builds a `Soap(body_secrets=...)` +outbound and asserts its env key appears in the returned map. Without the reverse assertion the +next entry added by hand repeats this exactly, and the comment at `:188-189` stays false. + +**Do not assume the rest of the set is clean.** An earlier draft asserted *"every other +rotatable connector credential rides `:725` correctly today"*; no check in this filing establishes +that, and the reverse assertion above is precisely the instrument that would. Treat the sweep of +the frozenset as part of the work, not as a settled fact. + +**"Moves no verdict" is right about the score and wrong about the record.** ASVS 13.3.4 stays +`partial` either way. But that cell's residual names this gap as extant and its re-anchor trigger +names `body_secret_value_*` joining or leaving the fingerprint set — so **landing this obliges a +same-day re-verify of the residual**. **The residual and its trigger live in the vault-only +`docs/security/asvs-scorecard.toml`** — `docs/security/` is gitignored here and `git ls-tree -r +origin/main -- docs/security` returns nothing, so a session that greps this repo for 13.3.4 will +find nothing and wrongly conclude the obligation is stale. The engine PR and the vault edit must +land **as a pair**, as the 13.2.2 (`1e9cc4c1` / `f2c017ce`) and 12.1.5 (`62fd628d` / `a8a5a1c2`) +pairings did. Say so in the PR body, or the code and the record drift apart in the very commit +that closes the gap. + +**Nearest existing mechanism:** none to build against — this *is* the mechanism, already shipped +and one predicate short. + +**Citation trap, flagged so it is not propagated.** `wiring.py:681-682` sources the prefix +branch to *"ADR 0015 amendment / BACKLOG #236"*. **That `#236` is an internal-ledger number and +does not resolve here** — public `docs/BACKLOG.md` #236 (`:2469`) is *"Test-this-step and +test-up-to-step with pinned upstream values"*, unrelated work. The two number spaces diverged +around #231 and overlap below 1000 by design; the overlap was deliberately left unrepaired +(`8e6e7fa3`: renumbering *"would only make stale citations resolve uniquely and WRONGLY"*). Cite +**ADR 0015** for this class, not a bare `#236`. + +**Trigger:** none — it is a defect, not demand-gated. + +**Related:** the absence-claim gate that proves syntax rather than behaviour (filed in the same +batch — the other instrument problem on the same ASVS cell); [ADR +0015](../../adr/0015-ws-soap-outbound-mtls-wssecurity.md) and its amendment, whose desugar +(`_hoist_body_secrets`) lives at `wiring.py:2249-2306` and is called at `:2414`; ADR 0158 (green +signals that mean nothing — the reverse-assertion half of this is an instance). + +**Source:** noticed during the ASVS build-or-accept costing pass, 2026-08-03, unrelated to any +cell that pass decided, and filed rather than folded into one. Re-verified against `origin/main` +`88703a3a` for this filing: the filter at `:725`, the frozenset at `:614-662`, the prefix branch +at `:686`, the exclusion set at `:697`, the desugar at `:2305`, the two `_is_secret_setting` +consumers at `:742` and `connection_schema.py:107`, and the registration plus gate comment at +`tests/test_secret_rotation_inventory.py:101` / `:182-209` were each read directly. + +--- + +## 1013. The `[auth] enabled=false` startup arm keys on the bind alone, so auth-off behind a declared terminator still starts + +> ✅ **Fixed 2026-08-06.** Value **7/10** · Difficulty **4/10** · _quick win_. The auth-off startup arm read `not settings.auth.enabled and not settings.api.is_loopback` (the bind alone), so it did not fire for a declared TLS-terminating proxy: a PHI instance with authentication **entirely off** behind a declared terminator would have started with **no refusal and no warning** on first deployment — while the same topology with auth ON but MFA off is refused by the gate #326 fixed. The two arms disagreed about what "exposed" means, in the same file, for the same topology. The auth-off arm now consults the single `instance_exposed` definition (hoisted above it), so it refuses on a non-loopback bind OR a declared terminator. + +**Cluster:** Security / startup gates. **Priority:** P1. **Verdict:** build. **Severity:** high on first deployment — no authentication at all on an off-loopback PHI instance. + +**Anchors, re-derived on `origin/main` at 17374679 now that #326 has merged.** These resolve today; verify them before starting. + +- `messagefoundry/__main__.py:1112` — `if not settings.auth.enabled and not settings.api.is_loopback:` — the auth-off arm. +- `messagefoundry/__main__.py:1917` — `instance_exposed = not settings.api.is_loopback or settings.api.tls_terminated_upstream` — the definition that already encodes the declared-terminator case, and now the ONLY one. +- `messagefoundry/__main__.py:1939` — `admin_exposed = instance_exposed` — #326's post-fix form, re-keyed onto the definition above. + +**The separation is the reason this is a separate item and not a one-line follow-on to #326.** `instance_exposed` is defined **805 lines BELOW** the auth-off arm, so the arm cannot reference it without hoisting the definition. #326 could re-key `admin_exposed` because the definition already sat above it; this cannot. + +**Why it is arguably worse than #326.** #326 was single-factor admin over the network. This is **no factor at all**. A deployment that follows the documented off-loopback topology, with a declared terminator and `[auth] enabled=false`, starts silently. + +⚠️ **THE REMEDY IS UNPROVEN — do not read this item as prescribing one.** Nobody has established that hoisting `instance_exposed` to the auth-off arm is safe. That arm runs **early** in the startup ladder, and whether the settings it reads are fully resolved at that point is unknown. **That ordering question is the actual work of this item**, not the two-line re-key it superficially resembles. + +> **AMENDED 2026-08-06 — remedy proven; the load-order question is resolved.** The prerequisite this item flagged as unproven holds. `instance_exposed`'s inputs are fully resolved where the auth-off arm runs: its two fields — `settings.api.host` (through `is_loopback`) and `settings.api.tls_terminated_upstream` — are read straight off the loaded config, and the only in-place mutation of `settings.api.*` between the arm and the former definition site is `serve_ui` (twice), which the predicate does not read. So the single definition was hoisted above the auth-off arm with a byte-identical value, and the arm was widened to consult it (refuse on a non-loopback bind OR a declared terminator). Exactly one definition site remains, per the pointer comment #326 left ("`instance_exposed` is NOT re-derived here") — the hoist shifts that comment's line, so it is named rather than pinned to a number. + +**#326 HAS LANDED** (PR #189), and the re-verification this paragraph asked for was performed at `17374679`: the arm moved `:1080` to `:1112`, `instance_exposed` moved `:2368` to `:1917`, `admin_exposed` is now `admin_exposed = instance_exposed` at `:1939`, and the separation narrowed from 1,288 lines to **805**. The duplicate definition at the former `:2368` is **gone**, replaced by a pointer comment at `:2454` ("`instance_exposed` is NOT re-derived here. It is defined ONCE, above"), so there is now exactly ONE definition site to move rather than two to keep in sync. **The load-bearing property survives the move and so does the difficulty-4 pricing:** the arm at `:1112` still sits ABOVE the definition at `:1917`, so it still cannot reference it without hoisting, and the ordering question is still the actual work. Only the numbers changed. + +⚠️ **A consequence of #326 that this item does not cover, and that no gate can see.** Re-keying `admin_exposed` onto `instance_exposed` means the MFA-at-exposure refusal now fires on a declared-TLS-terminator topology where it previously could not — a posture change under **ASVS 6.3.3**, whose citations all still resolve, so nothing went red. Raised by the vault drift-repair pass of 2026-08-04; 6.3.3 needs re-validating against the code rather than being assumed still correct. Not folded in here. + +**Related:** #326 (the sibling arm, same file, same gate family), #328. The ADR 0140 amendment on `plan-cli-exposure` records this residual but names no number, having been written before one existed — worth a follow-up edit now that this item is filed. + +**Source:** found by the #326 lane's own recon and handed over because filing needs `alloc.ps1` plus a ranked-table row, both outside a lane's permitted surface. The measurements are the lane's; the main-side anchors were re-derived at filing because the lane's numbers describe its post-fix tree and would not have resolved here. + +--- + +## 1015. OIDC relying party keys federated accounts on a reassignable username claim while the non-reassignable `sub` is discarded (ASVS 10.5.2) + +> ✅ **Closed 2026-08-06 — Option A shipped (subject-continuity guard); ADR 0142 Amendment A owner-ratified.** Value **7/10** · Difficulty **4/10** · _quick win_. The relying party keyed federated identity on a reassignable username claim while the non-reassignable `sub` was verified then dropped, so on first deployment a new holder of a retired username would have been handed the prior holder's account (ASVS 10.5.2). Fixed by pinning the federated identity to `(issuer, sub)` — two nullable store columns with idempotent three-backend migrations — and refusing a login whose username resolves to an account bound to a different `sub` (`federated_subject_conflict`); the account is still resolved by AD username and roles still come from LDAP. Residual: a legitimately reassigned username is refused with no rebind path, so an operator rebind action is the recommended follow-on. + +**Cluster:** Security / authentication. **Priority:** P1. **Verdict:** build. **Severity:** high on first deployment — account takeover without any credential compromise. + +**What is wrong.** `sub` is the only claim OIDC guarantees is stable and non-reassignable within an issuer. The RP verifies it and then discards it as identity, keying the local account on a display-oriented claim instead. Directory products reassign `preferred_username` routinely — a departed employee's name freed and reissued is ordinary lifecycle, not an attack. + +**Why value 7 and not higher.** It matches **#1013** (7/4): both are authentication-gate defects that admit the wrong principal. This one is more conditional — it needs an IdP-side reassignment — but it lands on an **existing** account rather than an empty one, which is why it does not sit below #1013. + +**Difficulty 4, and there is no migration cost.** Key on `(issuer, sub)` and keep the username as a mutable display attribute. Normally that is a data migration; here there are **zero deployments** (see CLAUDE.md §0), so there is no installed base to migrate. What remains is the model change, the AD/local-account interaction, and deciding what happens when an existing local username collides with a federated display name. + +**Related:** #1016 (same module, different failure class), ASVS 10.5.2. The V10 chapter report in the vault carries the full 14-item re-triage. + +**Source:** found during the ASVS V10 re-verification, 2026-08-04, and handed over because filing needs `alloc.ps1` plus a ranked-table row, neither of which is inside a build session's permitted surface. Confirmed as reported. + +--- + +## 1016. claims.py 500s on two malformed-IdP shapes with no closed-set audit row + +> ✅ **Fixed 2026-08-06.** Value 5/10 · Difficulty 2/10. Both malformed-IdP shapes — a non-ASCII nonce and a list `aud` carrying an unhashable element — now reject as named, audited ClaimsErrors (nonce_mismatch / claim_aud); on first deployment either would otherwise have surfaced as a 500 with no closed-set audit row. + +**Cluster:** Security / authentication robustness. **Priority:** P2. **Verdict:** build (small). **Severity:** low — availability and audit completeness, not an auth bypass. Neither path admits a bad principal; both turn a rejectable token into an unclassified 500. + +⚠️ **The two mechanisms below are NOT the ones originally reported, and the difference decides the fix.** Both were re-derived against the code at 32d0cef9 and tested directly. Filing the reported versions would have sent a fixer at checks that already exist. + +**1. `hmac.compare_digest` raises on a NON-ASCII str nonce.** Not "on two str" — two ASCII strings compare fine and return a bool. Measured: `compare_digest('abc','abc')` returns `True`; a non-ASCII operand raises `TypeError: comparing strings with non-ASCII characters is not supported`. And the guard reads `if not isinstance(token_nonce, str) or not hmac.compare_digest(...)`, so the `or` short-circuit means a non-str nonce can never reach the call — **type confusion is already closed, and non-ASCII is the ONLY remaining path.** The fix therefore belongs at the encoding boundary, not in an `isinstance` check that is already present. + +**2. `set(aud)` raises on a list containing UNHASHABLE elements.** Not "on a non-iterable". The line reads `audiences = {aud} if isinstance(aud, str) else set(aud) if isinstance(aud, list) else set()`, and measured, every non-list shape falls through cleanly — a bare int, `None` and a dict all yield an empty set with no error. The residual is a list whose elements are unhashable: a list containing a dict raises `TypeError: cannot use 'dict' as a set element`. + +**Why it matters more than a 500.** Both paths bypass the closed-set audit row that every other claim rejection emits, so a malformed or hostile IdP response becomes an unclassified error rather than a named, audited refusal — which is the record an operator would need to tell a broken IdP from an attacked one. + +**Related:** #1015 (same module, an identity-keying defect rather than a robustness one). + +**Source:** found during the ASVS V10 re-verification, 2026-08-04. The conclusions were reported correctly; both mechanisms were misstated and are corrected here, with the correction verified independently by the reporting session. + +--- + +## 1014. connscale smoke test's fixed 24-port block is not parallel-safe across worktrees; the flaky marker hides the collision + +> ✅ **SHIPPED 2026-08-06 — dynamic contiguous inbound-port allocation replaces the fixed 24-port block; the flaky marker is dropped.** Value **5/10** · Difficulty **3/10** · _fill-in_. `test_connscale_smoke_end_to_end` now reserves a random contiguous inbound-port block at runtime (`_free_contiguous_ports`), asserts contiguity at acquisition, and fails loudly if no free block is found, so a genuine cross-worktree collision surfaces as a red rather than a masked retry. + +**Cluster:** Testing / CI reliability. **Priority:** P3. **Verdict:** build (small). **Severity:** low — it costs retries and misdiagnosis, not correctness. + +**Why this is not a flake.** It was traced rather than assumed. There is no global `--reruns` in `addopts`, so only an explicitly-marked test can retry at all, and exactly two are marked; one skips locally. That leaves this test, carrying `@pytest.mark.flaky(reruns=2, reruns_delay=3)` with the comment *"CI runners are noisy: re-run clears"*. Three suites were run in parallel across three worktrees; two needed their retry and the third did not, because it won the race for the fixed block. + +**Why the label is the defect.** The retry is doing work the port allocation should be doing. Labelled *noisy runner*, a real contention bug becomes invisible — and this repo's own guidance is that a failure must be **proven** timing-dependent before being called a flake, precisely because the two previously-famous flakes here turned out to be a livelock and a test that was right. + +**The topology makes it routine, not exotic.** This project runs many checkouts of the same repo at once — **24 worktrees were live on 2026-08-04** — so "two checkouts at once" is the normal case rather than an edge case. + +**Proposed fix.** Allocate the block dynamically, assert contiguity at acquisition, and fail loudly if it cannot be obtained. Then remove the `flaky` marker, so a future collision is a red rather than a retry. Do not widen the retry count. + +**Related:** #340 (merge-queue serialisation — the other place this repo's parallelism outgrew a fixed assumption). + +**Source:** found by a build session while re-verifying five rebased lanes, 2026-08-04. It attributed the immediate trigger to its own parallel harness rather than to the branches under test, and handed the underlying defect over because filing needs a number and a ranked-table row. + +--- + +## 1021. The MFA enrollment confirm verifies the activating TOTP through a bool wrapper that discards the step, so it is never consumed (ASVS 6.5.1) + +> ✅ **Fixed 2026-08-06 — enrollment now consumes the activating TOTP step (`verify_totp_step` + `consume_totp_step`), mirroring the login path.** Value **6/10** · Difficulty **4/10** · _quick win_. `confirm_mfa_enrollment` proved the enrolling code through the `totp.verify_totp` bool wrapper, which computed the matched time-step then collapsed it to a bool, so the step was never recorded; with `last_totp_step` left NULL by `enable_totp`, the activating code would have remained usable on the login path for the remainder of its own step on first deployment. The confirm site now takes the matched step from `verify_totp_step` and requires `consume_totp_step` before minting recovery codes / `enable_totp`, so the step is single-use (ASVS 6.5.1) and enable stays atomic. + +**Cluster:** Security / authentication. **Priority:** P2. **Verdict:** build (small). **Severity:** would leave a narrow second-factor replay window at enrollment on first deployment — bounded, not a bypass. + +**Two facts combine, and the body needs both.** The confirm path discards the step (`auth/service.py:1979` calls `totp.verify_totp`; `auth/totp.py:150` computes the step then returns `... is not None`), and nothing seeds the high-water mark, so the discarded step is genuinely reachable rather than incidentally blocked: `enable_totp` updates only `totp_enabled`, `totp_enrolled_at`, `totp_recovery_codes`, `updated_at` in all three backends (`store/store.py:7752-7764`, `sqlserver.py:9095`, `postgres.py:6165`), leaving `users.last_totp_step` NULL, and the compare-and-set at `store/store.py:7824` accepts any matched step against a NULL mark. + +**The replay target is the login path, not a second confirm.** Code `C` proven at `POST /me/mfa/confirm` would still be accepted by `POST /auth/mfa-verify` on a separate, password-authenticated session for the same account. `totp_skew_steps` defaults to `0` (`config/settings.py:1736`), so the window is the remainder of `C`'s own 30-second step — roughly 60 or 90 seconds only under the documented 1/2 opt-in. Do not size it as plus-or-minus-one step. `confirm_mfa_enrollment` also lacks a `totp_enabled` guard, so a second confirm would re-succeed, but that route needs a fresh action-bound password step-up (`api/auth_routes.py:408`) and is the lesser path — do not build the fix around it. + +⛔ **The replay guard already exists. Do not rebuild it.** `verify_totp_step` already returns the matched step and already clamps a tolerated fast-clock code down to the current step (`auth/totp.py:90-132`, SEC-014); `_verify_second_factor` already does verify-then-consume on the login path (`auth/service.py:2061-2073`); the atomic compare-and-set exists in all three backends (`store/store.py:7811-7828`, `sqlserver.py:9155-9177` with UPDLOCK/ROWLOCK, `postgres.py:6217-6231` with FOR UPDATE), declared at `store/base.py:1588`; and login-path single-use is pinned by `tests/test_mfa.py:139`. **The only thing missing is the call at the enrollment site.** Note also that `disable_totp` leaves `last_totp_step` untouched — that direction is conservative and must not be "fixed" by clearing it. + +**Difficulty 4, and the cost is test collateral rather than code.** The production change is about three lines: switch `:1979` to `verify_totp_step`, keep the step, and require `consume_totp_step` before activating — consuming **before** `enable_totp`/`mark_session_mfa_verified`/minting recovery codes, and treating a `False` as a failed confirm on the existing `auth.mfa_failed` phase=enroll branch. At least four tests confirm an enrollment then assert a live verify inside the same step and would go failing or intermittently failing: `tests/test_mfa.py:81-94`, `:147-157` (sharpest — it reuses the same code object), `:272-281`, and `tests/test_step_up.py:314-318`. The obvious remedy does not work: `tests/_totp_clock.py`'s `fresh_totp` guarantees headroom **within** the current step and cannot advance one, so each affected test needs restructuring rather than a CI sleep across a 30-second boundary. + +**Both operator surfaces reach this through the one service method** — `POST /me/mfa/confirm` (`api/auth_routes.py:403-427`) and `POST /ui/account/mfa/verify` (`messagefoundry_webconsole/routes/account.py:239-272`) — so fixing the service method fixes both and no route change is needed. + +**Open question, not a blocker:** whether any security document states TOTP single-use in terms broad enough to be made inaccurate by this gap. `docs/BACKLOG.md:698` describes the per-user compare-and-set and is true as written. The vault scorecard was not readable from this checkout, so if 6.5.1 is scored fully met there, that cell needs re-validating against the code rather than being assumed still correct. + +**Source:** found during the ASVS V6 re-verification, 2026-08-04, and adversarially re-verified against the code at `6e481c14` before filing. Confirmed as stated. + +--- + +## 1025. Three `require_ui_step_up` routes emit PHI with no `phi=`, so they charge no per-actor read budget + +> ✅ **SHIPPED 2026-08-06 — the two content-search render paths brought under the per-actor read budget; the third route was already covered.** Value **5/10** · Difficulty **2/10** · _fill-in_. **AMENDED 2026-08-05 — scope corrected against the code before building.** The filing's premise (all three routes charge no read budget) does not hold: `search_messages`, `layered_search` and `browse_uploaded_file` each call `enforce_phi_read_pacing` in their own body — which the console executes when it invokes them directly — so every request that actually reaches a handler was already charged at the cited commit `e0482aea`. The real gap was only the console's SHORT-CIRCUIT renders (`GET /ui/messages/search` bare-form, `GET /ui/messages/search/layered` no-preset) that return *before* the handler runs. **AMENDED 2026-08-06 — mechanism corrected from a gate-level `phi=` to an inline branch charge.** A gate-level `phi=` on `require_ui_step_up` charges in the dependency, i.e. on *every* request, so it would have double-charged the criteria/preset path — which already charges in the handler — the exact double-count that excludes the uploaded route. Instead each search route now charges `enforce_phi_read_pacing` **inline on its short-circuit branch only**, so the bare-form / no-preset render spends a token while a real search still charges exactly once. `GET /ui/uploaded-logs/file/{file_id}` was deliberately left unchanged — it has no short-circuit and `browse_uploaded_file` paces every call, so any second charge would double-count the same budget (empirically the first browse would `429` at a budget of 1). **A missing rate limit, not a missing authorization check** — all three still gate on the right permission. Shipped: the two search short-circuit charges + the `require_ui_step_up` docstring corrected + `docs/SECURITY.md` and the webconsole CHANGELOG aligned to the true mechanism. + +**Cluster:** Security / PHI anti-automation. **Priority:** P2. **Verdict:** build (small). **Severity:** would leave three PHI-emitting console routes outside the per-actor read budget on first deployment, so an authorised-but-abusive actor could enumerate through them without hitting the 429 the sibling browse routes enforce. No unauthorised access. + +**Mechanism, verified at `e0482aea`.** `require_ui` declares `phi: bool = False` and throttles at `messagefoundry_webconsole/_auth.py:260` with `if phi and not auth.allow_phi_read(identity.user_id):`. `require_ui_step_up` builds its base as `require_ui(*permissions, allow_mfa_pending=True)`; unless `phi=` is passed through, the arm is unreachable. The three routes above pass nothing — `GET /ui/messages/search` is on `require_ui_step_up(Permission.MESSAGES_READ)`. + +**The plumbing already exists, so this is three call sites and tests.** #324 threaded `phi=` into `require_ui_step_up` (`_auth.py:498`, whose docstring records that `phi=True` "forwards to `require_ui`'s `phi` arm ... the same throttle the plain `require_ui(..., phi=True)` browse routes and the JSON `require_phi_read` routes charge") and used it on the edit route (`routes/core.py:612`). **Difficulty 2 is that inheritance** — before #324 this would have been the plumbing plus the call sites. + +**Copy the siblings that already do it right:** `routes/core.py:473`, `:483`, `:501`, each `require_ui(Permission.MESSAGES_VIEW_RAW, phi=True)`. + +**Related:** #324 (built the seam and the two edit routes; closed), #1027. + +**Source:** reported by the #324 lane rather than fixed in it, per the owner's settle that the lane thread `phi=` for its own route only and report the rest. Mechanism re-verified independently before filing. + +--- + +## 1027. The documented `pytest` command silently excludes the webconsole package, so a local green is not evidence about ~344 tests + +> ✅ **SHIPPED 2026-08-06 — the root `testpaths` now also collects `packaging/messagefoundry-webconsole/tests`, so a bare `pytest -q` from the repo root stops silently excluding the web console suite; the one webauthn-extra-dependent console test that lacked a guard (`test_webauthn_rp_fail_closed_legible`) now skips-with-reason when the optional `[webauthn]` extra is absent, so an extra-less local venv stays green.** Value **5/10** · Difficulty **3/10** · _fill-in_. Local developer-signal fix only — CI already covered the console via its dedicated `Web console tests (pytest)` step; the gap was that the documented local gate collected less than it appeared to. + +**Cluster:** Testing / verification integrity. **Priority:** P3. **Verdict:** build (small). **Severity:** no product effect; the defect is that the project's own verification instruction produces a green that is not evidence about roughly 344 tests, and CLAUDE.md §5 states a task is not done until it passes. + +**It is the documented command, which is what makes it more than a config default.** `CLAUDE.md:333` gives `QT_QPA_PLATFORM=offscreen pytest -q` as the way to run the suite, and `pyproject.toml`'s `[tool.pytest.ini_options]` sets `testpaths = ["tests"]`. Every session that followed the instruction measured a tree it believed was covered. + +**The evidence, and it is not hypothetical.** On 2026-08-04 `packaging/messagefoundry-webconsole/tests/test_webui.py::test_webauthn_rp_fail_closed_legible` was failing on `main` all day and no lane saw it. It surfaced only when one lane named both paths explicitly because it was editing `messagefoundry_webconsole/` directly — `pytest tests packaging/messagefoundry-webconsole/tests` returned `1 failed, 10681 passed, 851 skipped`. + +⚠️ **Not a CI gap — verified, not assumed.** CI runs `Web console tests (pytest)` as a separate required step and installs the extra the failing test needs (`.github/workflows/ci.yml:250` installs `-e ".[dev,harness,fhir,dicom,x12,xml,webauthn]" -e packaging/messagefoundry-webconsole`, and `:245` records that `[webauthn]` is there "so the passkey ceremony tests run real `verify_*` assertions"). So PRs have been merging on real coverage. **The gap is local only**, which is why it went unnoticed: nothing red ever reached anyone. + +**Difficulty 3 because the naive fix reds every local run.** Adding the packaging path to `testpaths` makes that same `[webauthn]` failure the default local experience, since worktree venvs bootstrap a narrower extra set than CI. So the item is really "make local coverage honest", and the options interact: widen `testpaths` **and** make the webauthn tests skip-with-reason without the extra; or leave `testpaths` and correct `CLAUDE.md` to document both paths; or have the venv bootstrap install the extra. Whichever is chosen, ⛔ **a skip must announce itself** — this project's own standard is that a skip reading as a pass is the failure being fixed here, so do not trade a silent exclusion for a silent skip. + +⭐ **The general shape, worth keeping when this is fixed.** *A citation nobody has broken yet and a citation nobody has noticed is broken look identical in a grep; only the change that breaks it can tell them apart.* The same is true of a test path: an excluded suite and a passing suite look identical in a green summary line. The fix is not to remember, it is to make the exclusion visible. + +**Related:** #1018 (guards that go quiet), #344 (the two test steps sharing one budget), ADR 0158. + +**Source:** found by the #324 lane on 2026-08-04 when it named both pytest paths for a webconsole-touching change; the CI-coverage half was flagged by that lane as an inference and verified against `ci.yml` before filing. + +--- + +## 1029. `/simplify` shipped as a local skill with no entry in the quality-standards record, so the one review tool that edits the tree had no written placement or scope + +> ✅ **SHIPPED 2026-08-05 — the documentation is the whole deliverable.** Value **3/10** · Difficulty **1/10** · _quick win_. `/simplify` is now recorded in [`docs/Code_Quality_Standards.md`](../../Code_Quality_Standards.md) §5.1 as a local, human-invoked **advisory** review that **applies** its fixes, ordered before the `ruff` / `mypy` / `pytest` quartet, with the justified-duplication carve-outs written down. A new §5.1, a scoping clause in §5's intro, a mapping row in §6, and a `Before you verify` heading in `CLAUDE.md` §5. + +**Cluster:** Documentation / quality-control record. **Priority:** P3. **Verdict:** build (small). **Severity:** no product effect and no security effect. The gap was in the record: the quality-standards document enumerated five measurement gates and named no review tool that rewrites code, so the one ordering constraint that matters and the scope limits that already follow from earlier decisions were unwritten and uncitable. + +**What the record now says.** §5.1 is a new subsection and the single home for the tool. §5's placement table is **unchanged at five rows** — an earlier draft added a sixth and was reverted, because a row declaring itself "not a gate" contradicted both that table's `Gate` column and the §5 heading, and forced the same caveat into three other places. §5's intro instead gains one scoping clause naming §5.1 as a review tool deliberately not among the five. §6's companion-mapping table lists it in the same local, human-invoked, advisory tier as `/code-review` and `/security-review`, with the one difference that separates them stated **once**: those two report findings a human arbitrates, this one applies edits. + +**No status is claimed for it, and that is deliberate.** Every other entry in this document names a tracked artifact and a pull request. `/simplify` ships with Claude Code rather than with this project, so there is no `.claude/` entry, pin, or other artifact in the checkout to score — **Built** is therefore a claim the document explicitly declines to make, citing the Appendix A honesty taxonomy. §4.0's liveness rule does not reach it either, because there is no green check to trust. + +**The ordering is a consequence of the report-versus-apply difference, not a convention.** A tool that applies fixes, run after the quartet, would mutate the tree the quartet had just certified. `CLAUDE.md` carries it as a `Before you verify` heading placed *ahead of* the verification-expectations list rather than inside it — it is a mandated pre-step, not a gate, and "a task isn't done until these pass" cannot govern something that emits no pass or fail. + +**The carve-outs are the part most easily lost, and they are an open class.** §5.1 records **at least** these deliberately-justified duplications as out of scope: the SQL Server / Postgres store-backend parity that signal 9's clone detection already whitelists, and the `messagefoundry/anon/` package vendored to `tee/anon/` under [ADR 0030](../../adr/0030-anonymization-test-harness-tee.md), which signal 9 cannot see at all because its `jscpd` scan covers `messagefoundry/` only. The defensive branching tolerant HL7 parsing requires (`CLAUDE.md` §8) is recorded separately as a signal 11 *complexity* concern rather than a duplication one. Nothing the tool produces certifies quality (§4.1); the maintainer owns every applied edit under the *reject code you cannot explain* floor. + +**Difficulty 1 because nothing was built.** The skill already existed and is unchanged; the deliverable is a subsection, a table row, a heading and a clause. It is filed closed rather than skipped so the placement decision has a number to cite. + +**Related:** #1027 (the quartet this ordering sits in front of, and the same class of defect — a verification instruction that does not say what it actually covers), #1006 (an advisory gate from the same rubric), #1000 (gate liveness, the rule §5.1 explicitly records as not reaching a non-gate). + +**Source:** filed alongside the documentation change itself, 2026-08-05, and rewritten before filing because the first draft described a structure that was subsequently reverted. Every claim above was read from the working tree at commit `17c52129` rather than recalled: §5.1 at line 221, the five-row gate table, the §6 row at line 242, `CLAUDE.md`'s heading at line 288, and both `Built` mentions confirmed to be negations. The same change removed all 41 status glyphs from that document (rubric v0.12) and marked its pull-request citations as `PR #N`, the bare form having already resolved to the wrong item for `#1020`. + +--- + +## 1031. The STEP4 bench doc restates the stage_residency docstring in the glyphs its source shed, and carries emoji + +> ✅ **SHIPPED 2026-08-05.** All 101 non-cp1252 characters removed from `docs/benchmarks/STEP4-bracket-and-littles-law.md` — the **whole file**, not just the §5.2 block enumerated below, which was written as a floor and was one. U+2264/U+2265/U+2212/U+2192/U+2190/U+2260/U+21D2 to their ASCII forms; U+03BB/U+03C3 to `lambda`/`sigma`; U+2261 to `==`; U+2227 to the word `AND`; the four U+26A0 + U+FE0F pairs to the word `WARNING`. U+2248 became the file's **own** bare-tilde idiom (`~62 ms`, `rho ~0.23`) rather than `~=` — `~=` is the PEP 440 compatible-release operator everywhere else in `docs/` and means NOT-EQUAL in MATLAB and Lua, which would have inverted the verdict rows at lines 373-375. U+00D7 deliberately KEPT (14 occurrences): it is cp1252-representable typography, not a glyph, and the source keeps 4. + +**Cluster:** Docs / consistency. **Priority:** P4. **Verdict:** build (trivial). **Severity:** none operationally. It is a documentation defect: a reader comparing the doc to the tool sees two renderings of one definition and cannot tell whether the difference is meaningful. + +**Where — lines 414-425, and at least these.** U+2264 twice and U+2212 once on line 416 (`N(t) = #transformed<=t - #delivered<=t`, which the source now writes in ASCII, matching what `stage_residency.py:557` already used); U+2192 on 417; U+2248 on 422, in the sentence the source now reads as "N is about 8, therefore the lanes are saturated"; U+03BB on 425; and U+26A0 + U+FE0F on 421 and 425. Enumerated by scan rather than by eye, but treat it as a floor and re-scan the range. + +**Do not "fix" U+00D7 — the source keeps it.** `stage_residency.py` still contains four multiplication signs, including on the same sentence as doc line 425. It is cp1252-representable and out of scope for §11. Converting the doc's copy would *create* a divergence rather than remove one. + +**The source is cp1252-safe, not ASCII.** It retains 70 em dashes and those four multiplication signs. Em dashes, ellipses and section signs in the doc are cp1252-representable typography and stay. + +**Nothing machine-compares them, which is the point.** No gate reads both, so this did not go red and will not. It is the shape #1030 exists to catch, and if #1030 lands with docs in scope this closes as a side effect — check that before doing it by hand. + +**Related:** #1030 (the missing gate that would have caught this), #1027. + +**Source:** found by the completeness pass over the `scripts/` glyph sweep on 2026-08-05; the codepoint enumeration was corrected by an adversarial pass that caught the first draft claiming U+00D7 as a divergence and missing the U+26A0/U+FE0F pair entirely. + +--- + +## 1032. `worktree_gate` Rule 3b prints a `new.ps1` command that `new.ps1` rejects + +> ✅ **SHIPPED 2026-08-05 — merged as PR #214 (`fdaf53f7`).** Value **6/10** · Difficulty **3/10** · _fill-in_. The Rule 3b deny's escape hatch could not be executed for the case that triggers it: it interpolated a slash-bearing branch name into a parameter that forbids slashes, which is 143 of 196 local branches. Reproduced by running it, not by reading it. `new.ps1` gained a `-Branch` parameter distinct from `-Name` and the rule now emits both. The same work closed a refname **command injection** in that deny text (#1040) and a hijack **bypass** the first attempt introduced — rule 3b deferred to a git guard that `--ignore-other-worktrees`, `--detach` and `-d` all switch off, on both `checkout` and `switch`, so the fix is an allowlist (deny on ANY flag) rather than a list of known bypasses (#1039). Verified in main: `ConvertTo-WorktreeSlug` present in `scripts/hooks/worktree_gate.ps1`. + +**What.** `scripts/hooks/worktree_gate.ps1:388`, inside the Rule 3b deny ("BLOCKED: would switch a LINKED WORKTREE onto the existing branch"), tells the caller to give the branch its own worktree with: + +``` +pwsh -NoProfile -File $newHint -Name $dest +``` + +`$dest` is the **branch** name. `scripts/worktree/new.ps1:26` validates `-Name` against `^[A-Za-z0-9._-]+$`, which every slash-bearing branch fails. Measured 2026-08-05: a branch of the form `claude/-` is REJECTED while the bare `` component is accepted, and **140 of 193 local branches carry a slash**. The gate's motivating case is a branch that already exists — which is exactly why it carries a `claude/` prefix — so the escape hatch fails in the default case, not an edge case. + +**Why it survived.** The other three sites (`:411`, `:685`, `:794`) print the placeholder `-Name `, which is valid. `:388` is the only interpolating one, so a grep for the common form finds three healthy instances and misses the defect. Two independent readers hit exactly that; the one who found it had run the command and held the failure in hand first. + +**DO NOT fix this by relaxing the ValidatePattern.** `$Name` does two jobs and the pattern is load-bearing for the first: + +| line | use | +|---|---| +| `new.ps1:43` | `Join-Path $Parent "$RepoName-$Name"` — a **path component** | +| `new.ps1:58`, `:72`, `:86`, `:97` | `git branch --list` / `worktree add` / the `mefor-home-branch` marker — a **ref** | + +A slash satisfies git as a refname but makes `Join-Path` build a nested directory. Measured: a `claude/` branch yields `MessageFoundry-claude\` instead of a sibling `MessageFoundry-`, so the worktree lands one level deeper than every other one. Loosening the pattern alone converts a **loud correct failure into a quiet wrong success** — the worse direction of error. + +**Preferred fix.** Add a `-Branch` parameter distinct from `-Name` (name = directory component, branch = ref), defaulting `-Branch` to `-Name` so every existing caller is unchanged; then `:388` emits `-Branch $dest -Name `. **Fallback** if a new parameter is unwanted: stop printing a command that cannot work, and print the supported procedure instead. + +**Verification this item must demand.** A test that **executes the string the gate prints**, not one that asserts a copy of it — a test hard-coding the expected hint passes throughout this defect, which is the "guard tests a copy of the rule" trap and is how it survived. It must also assert the resulting worktree directory is a **sibling**, since that is the regression the current validation prevents and that a naive fix would introduce. + +**Related:** #1030 (the missing general gate), #1027. + +**Source:** found by session `sleepy-villani-df328d` while gate-blocked twice, correctly, from another session's branch; reproduced independently by the coordinator against `new.ps1:26` and `Join-Path`. A Claude Code task chip (`task_fb78da2c`) covers the same defect but carries no allocated number and will not survive the session, so this ledger entry is the durable record. + +--- + +## 1034. The pre-push shim fails OPEN when python is not on PATH, so the push guard silently does not run + +> ✅ **SHIPPED 2026-08-05 — merged as PR #215 (`09c6fe8e`) and PR #217 (`e75cff02`).** Value **7/10** · Difficulty **3/10** · _fill-in_. The headline defect and both "adjacent gaps" below are fixed. #215: both generated shims now refuse instead of exiting 0 when neither `python` nor `python3` resolves, and name `--no-verify` so a fail-closed gate does not get "fixed" by deleting it. #217: `MEFOR_ALLOW_DIRECT_PUSH` is scoped to the protected-branch guard alone, so it no longer disarms the namespace and content guards it was never named for; and a tip tree the guard cannot READ is refused rather than assumed clean, because "there is nothing there" and "I could not look" are different facts. Proven against the pre-fix code rather than asserted: the old shims exit 0 with no interpreter on PATH, and the old guard permits both a branch and a tag carrying `docs/security`. **What did NOT ship is this item's own prescription** — "the durable answer is server-side" is measured DEAD on both halves (a push ruleset returns `422 Source public repos cannot have push rules`; `enforce_admins` governs protected branches and so cannot see a feature branch). That residual, and the fact that no server-side content control exists here at all, is **#1056** — this item is closed on its title, not on that finding. + +**What.** The shim is generated by `scripts/coord/install-git-hooks.ps1` and shared by every worktree through `core.hooksPath`. When it cannot find python it prints its notice to stderr and returns 0, allowing the push. That is the correct posture for a *workflow* guard that should not wedge a developer, and the wrong one for the only remaining control on a publication path — the same fail-open-versus-fail-closed distinction the security standards already draw between the git-staging guard and the engine's bind guard. + +**Why it matters more since 2026-08-05.** `push_guard.py` gained two further checks that day: a namespace allowlist (refusing a `--mirror`-shaped push) and a tip-tree check (refusing a ref carrying `docs/security`). Both are defeated by the same fail-open, so the shim now switches off three guards rather than one, and the failure is silent in the noisiest possible place — a terminal line above a successful push. + +**Two adjacent gaps in the same class**, worth deciding together rather than separately: + +- A **fresh clone or a newly created worktree has no hook at all** until `install-git-hooks.ps1` runs. Nothing prompts for it. +- `git push --no-verify` and `MEFOR_ALLOW_DIRECT_PUSH=1` skip every check by design, and the latter returns 0 before any guard runs despite reading like it permits one specific thing. + +**A client-side hook cannot be the sole control, and that is the real finding.** Any fix here reduces the likelihood of an accident; it does not close the path. The durable answer is server-side — re-enabling `enforce_admins`, or a push ruleset — with the shim hardened as defence in depth rather than as the boundary. Whatever is decided, no prose may describe the hook as a security boundary; its own docstring already refuses that framing and should keep refusing it. + +**Related:** \#1032 (same file family, and the same shape of a remediation that cannot execute), PR #209. + +**Source:** surfaced 2026-08-05 while adding the two new guards, from the observation that a guard everything else leans on can be switched off by a missing interpreter. Held for the owner: another session has it as analysis only, with no build decision taken. + +--- + +## 1041. Rule 3d tells a session removing its OWN worktree that it belongs to another session + +> ✅ **SHIPPED 2026-08-05 — the false premise is gone and the cwd check is now made rather than argued for.** Value **4/10** · Difficulty **2/10** · _fill-in_. Rule 3d resolves the victim's toplevel and the session's own and compares them, so a session acting on the tree it is standing in gets a deny that says exactly that, instead of being blamed on a session that does not exist. **Scope, stated because the item's title is broader than the fix:** this establishes *"this IS the tree you are standing in"*, which is the only ownership fact available here. It does **not** establish the converse — a worktree that is not yours to stand in may still be nobody's, and the rule still has no occupancy or authorship signal to tell an abandoned tree from a live one. The sibling deny therefore still refuses, and now says it cannot tell rather than claiming it knows. A caller who *created* a worktree and removes it from elsewhere is still refused; that case is unaddressed and needs an occupancy signal, not a text change. Three regression tests, each confirmed failing against the pre-fix gate first — the sharpest being that the two denies were previously **byte-identical**, which is the defect in one line. Original filing follows. `scripts/hooks/worktree_gate.ps1:528` justified rule 3d with *"git refuses to remove the worktree you are STANDING in -- so a `worktree remove` that reaches git is, by construction, aimed at somebody else's."* The gate is a **PreToolUse** hook, so it runs **before** git: git's refusal never happens, the inference is never tested, and the deny at `:563` asserts *"belongs to ANOTHER SESSION ... so this one is not yours"* for every governed worktree including the caller's own. + +**Cluster:** Session-drift controls / refusal accuracy. **Priority:** P3. **Verdict:** build (small). **Severity:** no data loss — the deny is *correct as a decision* and it does prevent an accidental self-deletion. The defect is entirely in what the text tells the reader to do next, which CLAUDE.md §11 treats as a correctness property: *"a gate that misdescribes the thing it blocked trains people to route around it"* (recorded at `worktree_gate.ps1:646` for the sibling case #308 already fixed). + +**Reproduced first-hand on 2026-08-05, not reasoned from source.** A session standing in a linked worktree under `/.claude/worktrees/` ran `git worktree remove ` and received rule 3d's refusal verbatim: *"acts on a worktree of `` that belongs to ANOTHER SESSION -- git refuses to remove the worktree you are standing in, so this one is not yours."* Both clauses are false in that run. Nothing was deleted, because the hook denied the whole command before git executed — which is also precisely why the premise cannot hold. + +**Why the inference fails, stated once.** The premise is a claim about what reaches git. A PreToolUse hook decides *whether anything reaches git at all*, so it can never observe the state its own premise depends on. Any rule that defers to a downstream layer's guard has this shape; here the deferral is unconditional and the guard is unreachable. + +**The remedy text compounds it.** The refusal closes with *"I want to remove the worktree `` and I need you to confirm it is not in use."* For the caller's own worktree that sends the operator to verify a fact that is false by construction — the worktree is in use by the session asking. The other two suggestions (`prune-merged.ps1`, `git worktree list`) stay correct. + +**The fix is local and the value is already computed.** Rule 3d resolves `$victimCmp` at `:554` for its governed-root test at `:557`. Comparing it against the session's own toplevel — `git -C $cwdRaw rev-parse --show-toplevel`, the same call rule 3b already makes — splits the two cases: a peer's worktree keeps the current text, and the caller's own gets an accurate one (git will refuse this itself; if you mean to discard the worktree, that is the user's call from a plain terminal). Difficulty 2: one comparison, one branch, and a regression test per branch. Do not simply *allow* the self case — the deny is the right decision, and blocking an accidental self-deletion is worth keeping. + +**Do not fix by deleting the premise sentence.** It is load-bearing documentation of *why* rule 3d has no cwd check, so removing it leaves the missing check unexplained. Replace it with what is actually true: git's guard is unreachable from here, therefore the rule must decide ownership itself. + +**Related:** #308 (the same defect class — a refusal describing something the reader cannot act on — fixed for the nested-worktree subpath), #1018 (guards that go quiet), ADR 0158. + +**Source:** reported by a concurrent session while it was fixing rule 3b's remediation text, verified independently against the source rather than relayed, then reproduced live by accident when a second session ran the command against its own worktree. Filed by the session that verified it, which is not building it; the reporting session offered to take it if the owner scopes it there. + +--- + +## 1060. `alloc.ps1` records the owning worktree from the current directory, so an absolute-path invocation misattributes it + +> ✅ **SHIPPED 2026-08-06 — every `git` call in both allocators is anchored to the script's own checkout, and the defect was larger than filed.** Value **5/10** · Difficulty **2/10** · _quick win_. `$repo` now comes from `git -C $PSScriptRoot rev-parse --path-format=absolute --show-toplevel`, and every subsequent call takes `-C $repo`. **`git -C`, not `Split-Path`:** the recorded `worktree` value is *compared* — by `ledger_check.py:227` and by `-List` — so its string form is part of a contract, and `--path-format=absolute` keeps writing the forward-slash form every claim already on disk carries. **The filing named one of four cwd-derived reads.** Also wrong, and measured on the pre-fix code: the `$branch` recorded with the claim was the *caller's* branch; the floor's boundary was parsed from the **caller's** `scripts/hooks/ledger_check.py`; and the floor's working-tree term — the one whose job is to catch a number written but committed **nowhere** — read the **caller's** `docs/BACKLOG.md`. That last one is not friction: a number drafted in the target worktree was invisible to the sweep and free to re-issue, which is the collision this script exists to prevent. **`scripts/coord/claim.ps1:54` carried the same construct and was never filed** — found by inspection while fixing this, fixed in the same commit; its enforcing hook `claim_check.py` reads the repo from cwd and is *right* to, because a commit hook's cwd **is** the committing worktree. Hook right, tool wrong, and only the tool can be invoked from elsewhere. The item's cheap second half — printing the recorded worktree at allocation time — was **already built** (`claimed by:`); what was missing is a note when the shell is standing somewhere else, which is now printed. Tested by the **divergence** with `-ShowFloor`, so no numbers were burned: two temp checkouts drafting different numbers, the allocator invoked by absolute path from the other one. The negative control was run, not assumed — reverted, the same test reports `floor : 7777`, `boundary : 1900` and a watermark under `Caller/.git/`, all three signals pointing at the wrong tree. Original filing follows. `scripts/coord/alloc.ps1:51` took the owner from `git rev-parse --show-toplevel`, which resolves against the **current directory** rather than the script's location. Invoke it by absolute `-File` path from a different worktree — which is how a session with several worktrees naturally calls it — and the allocation is recorded to the caller's worktree while the commit comes from another. The ledger gate then refuses that commit correctly, but far away from the cause and with a message about the wrong thing. + +**Cluster:** Session coordination / ledger integrity. **Priority:** P3. **Verdict:** build (small). **Severity:** no data loss and no security effect — the ledger gate **fails closed**, which is why this is a friction defect and not a correctness one. Nothing invalid lands; a valid commit is refused. + +**SEVERITY CORRECTION, 2026-08-06, made while fixing it.** The paragraph above is right about the *recorded owner* and wrong about the item as a whole, because the filing looked at one line and the defect was in four. The ledger gate does fail closed on a misattributed claim — but the floor's **working-tree term** was cwd-derived too, and that term has no gate behind it. Its job is to see a number written to `docs/BACKLOG.md` and committed **nowhere**; reading the caller's tree makes a number drafted in the *target* worktree invisible, so the allocator hands it out as free and two items end up sharing it in the same tree. Both are then owned by that worktree, so `owns()` passes and the ledger gate never fires. That is the exact silent collision the script's own docstring says it exists to prevent, arrived at through the script. The window is narrow — anything committed on any ref is still caught by the all-refs term — but it is a correctness hole, not friction, and it was reproduced in the negative control rather than reasoned about. + +**AND THE FIX CONVERTED TWO SANDBOXED TESTS INTO WRITERS ON THE LIVE REGISTRY, which is the part worth remembering.** `tests/test_coord_claim_{refresh,liveness}.py` ran the **real** `scripts/coord/claim.ps1` with `cwd` set to a temp repo — scoped to a throwaway registry *purely by ambient cwd*, and one of them said so in a docstring: *"it scopes itself to the cwd's repo"*. The moment the script stopped consulting cwd, the passing half of the run wrote real claims into this clone's shared registry (two strays, removed by hand). So a cwd-dependence that looks like a defect in the tool can be load-bearing **isolation** in its tests, and removing it is a change to both. `tests/test_ledger_check.py` already staged a copy of `alloc.ps1` inside its fixture, which is exactly why it was the one file that did not break — the pattern existed and the two claim files were the outliers. Both now stage and commit the script into the fixture, so the sandbox is structural rather than ambient. Anyone fixing the remaining instances of this class (#1057, #1059) should check what their tests are isolated *by* before changing what the code reads. + +**Reproduced 2026-08-05, twice, by accident.** A session ran `pwsh -NoProfile -File /scripts/coord/alloc.ps1 -Kind backlog` from worktree A while intending to commit from worktree B. `alloc/backlog/1058.json` recorded `"worktree": "<...>/trusting-wu-c2e6d5"`. The commit from `MessageFoundry-gate-deferrals` was then refused: *"BACKLOG item #1058 was not allocated to this worktree"* — true, unhelpful, and pointing at the allocator rather than at the invocation. Re-running with the shell actually inside the target worktree produced `1059.json` with the right owner and the commit went through. **#1058 is an abandoned hole**, which is the sanctioned outcome (`alloc.ps1`'s own docstring: *holes are free, collisions are not*). + +**The fix is small and there are two defensible shapes.** Either derive the repo from `$PSScriptRoot` so the allocator is anchored to the checkout it lives in — matching what `new.ps1` and `remove.ps1` already do — or keep the cwd behaviour and **say so at the point of use**, printing the recorded worktree in the `ALLOCATED` output so the mismatch is visible immediately rather than at commit time. The second is weaker but nearly free, and the two compose. Prefer anchoring: an allocator invoked by absolute path is being told which checkout to act on, and it should not then consult a different one. + +**Do not fix by making the ledger gate more lenient.** Its refusal is correct and is the only reason this was noticed at all. The defect is that ownership was recorded wrongly, not that it was enforced. + +--- + +**THE SHARED PREMISE, which is larger than this item and is why it is worth reading here.** Three independent mechanisms in this repo assume, silently, that **where a command runs is where the caller is**: + +- **This item.** `alloc.ps1` resolves the owner from the current directory, not from the path it was handed. +- **#1059.** The worktree gate resolves a command's target as a literal string against the session's cwd, so a path arriving through a shell variable falls back to the caller's own worktree — and a command aimed at the shared primary is allowed. +- **#1057.** `occupancy.ps1` places sessions by cwd, so it cannot see a session writing into a worktree by absolute path from elsewhere. Measured on this repo: **0 occupants reported for a worktree that had been committed to a minute earlier.** + +`occupancy.ps1` already discloses the rate: **a session acting on a worktree by absolute path from elsewhere is 29% of writes on this repo**, by the project's own measurement. So the premise is not merely unstated, it is false about one write in three. + +**All three fail silently, and all three fail in the benign-looking direction** — a deny naming the wrong worktree, an owner recorded as the wrong worktree, an occupancy of zero for a worktree in active use. None raises. Each looks like a working answer. + +**All three were found by accident, none by looking**, which is the part that should not be trusted. Three instances is a coincidence-sized sample, and the honest next step is a targeted sweep for the shape — anything resolving a target from `--show-toplevel`, `getcwd`, or an unqualified relative path *when it was handed an explicit one* — which either produces a fourth concrete instance or shows three was the whole set. That is deliberately **not** filed as a theme item: "three mechanisms share a premise" has no fix and no closing condition, and would sit open describing something true. The premise is also recorded in [`docs/WORKTREES.md`](../../WORKTREES.md), so it outlives this item's closure. + +**Related:** #1059 (the gate instance, and the severe one), #1057 (the occupancy instance), #1000 (all three are green because they cannot see). + +**Source:** found 2026-08-05 while filing #1059, when the ledger gate refused a commit whose number had just been allocated successfully. Filed as the concrete defect rather than as the pattern, on the argument that a near-duplicate of an already-owned class dilutes the ledger — the same argument this session used earlier to decline filing a sibling to #1000. + +--- + +## 1063. `setup-leak-gate.ps1` picks the checkout from the current directory, so it can arm a worktree the operator did not name + +> ✅ **SHIPPED 2026-08-06 — the root is anchored to the script's own location, and the divergence is under test.** Value **3/10** · Difficulty **1/10** · _quick win_. `$repo` now comes from `Split-Path -Parent (Split-Path -Parent $PSScriptRoot)`, the form `postgres.ps1` and `sqlserver.ps1` in the same directory already use, plus an assert that the derived root actually carries `scripts/security/` — a wrong root should say so at the point of derivation rather than surface later as a confusing scanner failure. Tested by the **divergence**, not the happy path: two temp checkouts that both carry `scripts/security/`, the script invoked by absolute `-File` path while the shell stands in the other one, asserting the token list lands in the checkout holding the script and **not** in the caller's. The pre-fix behaviour was reproduced directly rather than inferred — reverted, the same test reports *"the named checkout was not armed"*. Only the three files the script reaches for are copied into the fixture, never the whole of `scripts/security/`, because a maintainer running the suite has the real token list sitting in that directory. Original filing follows. `scripts/dev/setup-leak-gate.ps1:37` was `$repo = (& git rev-parse --show-toplevel 2>$null)` — no `-C`, no `-Repo` parameter, no `$PSScriptRoot` anchor. Invoked by absolute `-File` path from a different worktree, which is the ordinary shape on a clone with 40-plus of them, it installs the leak-gate token list into **the current directory's** checkout and prints `CONFIGURED` about that one, while the worktree the operator named keeps no token source. Its own directory siblings already do it correctly. + +**Cluster:** Developer tooling / configuration anchoring. **Priority:** P4. **Verdict:** build (trivial). **Severity:** **low, and the low severity is load-bearing** — every failure direction here is loud or fail-closed, which is why this is filed at 3 rather than alongside its siblings. Nothing is silently ungated and no wrong authorisation is granted. + +**Why it is nearly harmless, stated so nobody escalates it on the family resemblance.** The named worktree's pre-commit leak gate keeps failing **closed** — it passes `--require-tokens` deliberately, so a missing token source blocks commits loudly rather than letting content through. And if the destination is not git-ignored, the script deletes the file it just wrote and throws rather than risk committing the token list. The wrong tree genuinely gets a working gate; the right tree keeps refusing. The cost is a confusing `CONFIGURED` and a second run, not an exposure. + +**The fix is one line and the pattern is already in the same directory.** `scripts/dev/postgres.ps1:37` and `scripts/dev/sqlserver.ps1:56` both use: + +```powershell +$repo = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +``` + +Anchoring on `$PSScriptRoot` binds the script to the checkout it lives in, which is what an absolute `-File` invocation is asking for. `-From` names the token **source**, not the checkout, so it does not already cover this. + +**Same construct as #1060.** `alloc.ps1:51` is the same construct — `git rev-parse --show-toplevel` with no anchor — and produces the same class of wrong answer — there, a misattributed ledger allocation; here, a token list installed into the wrong tree. Fixing them together is reasonable; filing them together was not, because their severities differ by two priority bands and folding this into #1060 would have inflated it. (The filing said *byte-equivalent*. It is not: `alloc.ps1` carries `--path-format=absolute` and this script does not. The defect is identical; the bytes are not, and "byte-equivalent" is the kind of claim a later reader greps for and then trusts.) + +**How it was found, and why that matters more than the defect.** A repo-wide sweep for the cwd-as-identity shape assigned five surfaces and left `scripts/dev` and `scripts/service` in **no** surface at all — seven `.ps1` files in the seam. This was found only because the synthesising agent went outside its brief and swept the unassigned region. A measuring apparatus with a blind spot, hunting mechanisms with blind spots. Worth remembering when the next sweep is designed: **state the unassigned regions, or the result reads as completeness.** + +**Related:** #1060 (the same construct, and the cwd-is-not-the-caller premise recorded in `docs/WORKTREES.md`), #1057, #1059, #1062 (the rest of that cluster), #1000 (the sweep's own coverage gap is that item's shape in a measuring tool rather than a gate). + +**Source:** found 2026-08-05 during the sweep that produced #1062, held unfiled overnight as explicitly marginal, and filed 2026-08-06 on the judgement that a real defect with a known one-line fix is worth a number even at P4 — a low severity is a priority statement, not a filing criterion, and unfiled findings get dropped. + +--- + +## 1062. `check` validates the env value file under `--project-root` then reads the values from the current directory + +> ✅ **SHIPPED 2026-08-06 — the root is threaded through and applied the way `serve` applies it.** Value **7/10** · Difficulty **2/10** · _quick win_. `run_checks` gained a `project_root` parameter, threaded to the build check and set as a `[environments].base_dir` **CLI override** — the same mechanism `serve` uses, so `load_settings`' CLI > env > file precedence puts it above a file-set `base_dir`. Left unset the resolution is unchanged and still falls back to the process directory, so `check --config config` is untouched. Two tests, asserted by the DIVERGENCE (the process directory holds its own value file with a different host); the pre-fix behaviour was reproduced directly rather than inferred — values were read from the process directory while the root was the one validated. Original filing follows. `messagefoundry check --project-root R` anchors `--config` under `R` and **hard-fails** if `R//.toml` is absent — then drops `R`. `run_checks` takes no project root, so the build check re-derives the value anchor from `Path.cwd()`. The gate therefore **verifies the file under the root you supplied and reads the values from wherever your shell happens to be.** `serve` does not have this defect, in the same file, by one line. + +**Cluster:** Configuration anchoring / gate integrity. **Priority:** P2. **Verdict:** build (small). **Severity:** would mis-decide a **required, blocking** check on a deploying site. Nothing is deployed (§0), so this is what a deploying site would hit on first use, not something happening today. It is also the only finding in this cluster on **product code** rather than developer tooling. + +**Verified by reading the chain end to end, 2026-08-06.** Not inferred from a grep: + +``` +__main__.py:832 root = resolve_project_root(args.project_root, cwd=cwd) +__main__.py:833-4 config_dir / service_config anchored under root +__main__.py:848-52 EXPLICIT root + --env -> hard-fail if //.toml is absent +__main__.py:853 return config_dir, service_config <-- root is DROPPED here +__main__.py:4263+ run_checks(config_dir, ..., service_config=...) <-- no root parameter exists +checks.py:1304 resolve_values_base_dir(settings.environments.base_dir, cwd=Path.cwd()) +environments.py:79 `if not base_dir: return cwd` <-- and base_dir is unset by default +``` + +**`serve` gets it right one screen away.** `__main__.py:1086` does `cli.setdefault("environments", {})["base_dir"] = args.project_root` *before* `load_settings`, and the comment at `:1095` records that this is exactly why. `check` never sets it, so `settings.environments.base_dir` stays empty and `resolve_values_base_dir` falls back to the process directory. + +**The comment above the defect claims the parity that is missing**, which is the sharpest evidence it is an oversight rather than a decision. `checks.py:1300-1302` reads: *"Resolve env() against the active environment **the same way serve does**, so a hop's host/scheme (an env()-supplied value) is built exactly as at runtime rather than left as an unresolved reference."* Serve's way **is** the `base_dir` assignment. The comment states the goal and the code omits the step that achieves it. + +**Consequence, in the conditional.** `build-check` is a required blocking check whose stated job is the ADR 0092 posture-keyed insecure-hop refusal, and the hosts and schemes it judges are `env()`-supplied. Run as `check --project-root R --env prod` from a directory `W`: + +- **If `W` holds its own `environments/prod.toml`** — the refusal is decided against **W's** values while the operator was told `R` was validated. A cleartext egress hop that `R` forbids could pass with exit 0. No diagnostic names which directory was read: `_emit_anchor_diagnostics`, including the AC-4 "cwd differs from root" warning, is **serve-only**. +- **If `W` holds no `environments/`** — a spurious blocking failure reporting a missing value file, which is loud but points at the wrong directory. + +**Reachability, stated honestly.** Nothing in this repo's CI, hooks or scripts passes `--project-root` to `check`; the shape is the documented consumer / config-repo invocation, which ADR 0050 AC-6 ratifies. So it is **supported but not exercised here** — which is also why no test caught it. Do not write this up as "unreachable": the invocation is the one a config repo is told to use. + +**The fix is the line `serve` already has.** Either give `run_checks` an explicit project-root parameter and thread it to the anchor, or have `check` set `[environments].base_dir` from `--project-root` before settings load, exactly as `serve` does at `:1086`. The second is smaller and makes the two paths converge rather than diverge further; the first is more explicit about what `run_checks` depends on. Either way `_check_build` must stop consulting `Path.cwd()` when a root was supplied. + +**Test it by the divergence, not by the happy path.** The case that matters is `--project-root R` run from a `W` that holds a *different* `environments/.toml`, asserting the value actually used comes from `R`. A test run from inside `R` passes with the bug in — the same shape as the Windows-versus-Linux masking that hid the rule 3d defect, and per #1000 a control needs the case that can distinguish. + +**Related:** #1057, #1059, #1060 (the cwd-is-not-the-caller cluster — this is its fourth instance and the only one on product code), #1000 (a required check green because it read the wrong directory), ADR 0050 AC-6, ADR 0092. + +**Source:** surfaced 2026-08-05 by a repo-wide sweep for the cwd-as-identity shape, reported as one of five candidates and held as **relayed, not confirmed** until the chain was read end to end on 2026-08-06. Filed only after that verification: the sweep's own severity ranking put it first, and a subagent's severity claim is not evidence. + +--- + +## 1073. Mine the free ASCQM 1.1 weakness catalogue against the existing gates; decline ISO 5055 as a measure + +> ✅ **SHIPPED 2026-08-07 — the pass ran over all 74 live elements; the measure stays declined.** Value **4/10** · Difficulty **3/10**. Findings filed as #1089, #1090, #1091, #1092 and the #1093 inventory. The decline marker now sits in [`../CLAUDE.md`](../../../CLAUDE.md) §12, which is the part that outlives this item — a decline recorded only here would vanish when this item archives, exactly as #26 and #27 would have. Original filing follows. ISO/IEC 5055:2021 defines four quality measures as **counts** of CWE-keyed severe weaknesses. The **measure** is declined for the reasons below and should not be re-litigated. The **catalogue** behind it is free, curated by a standards body, and contains a slice worth one bounded pass: the system-level weaknesses that a unit-level linter structurally cannot see. + +**THE COUNTS ARE RESOLVED, and the conflict was a UNITS problem nobody had named.** CISQ's 74 / 74 / 29 / 15 counts **CWEs**, including contributing child CWEs. ASCQM's 22 / 29 / 15 / 20 counts **elements**, and one element carries several CWEs — which is why the element count is roughly a third of the CWE count. Measured from the spec: **84 elements, 74 live, 10 marked Dropped by the standard itself.** Security 22, Performance Efficiency 15 and Maintainability 20 reconcile **exactly**; Reliability came to 27 against 29 expected, and `ASCRM-RLB-13` carries no CWE mapping — both are **known shortfalls, not resolved**. **Performance Efficiency = 15 is now CONFIRMED** from the spec and its unverified mark is lifted. **The "139 total" stays UNCONFIRMED**: 152 distinct CWE references appear across the 261 pages, but that is a mention count over the whole document including front matter, so it neither confirms nor refutes 139. That mark stays, and it is doing its job. + +**THE FIRST RUN SILENTLY EXAMINED 62 OF 74 ELEMENTS, AND THE RESULT LOOKED COMPLETE.** One of six triage batches died on a connection error. The surviving five returned a confident report with a headline gap count, and **nothing in it indicated that a sixth of the catalogue had never been read.** The 12 unexamined elements spanned all four measures and included three Security elements (CWE-99, CWE-456, CWE-789) — and CWE-456 became a filed finding (#1093) once actually judged, so the omission was **not** harmless. It was caught by arithmetic (62 + 12 = 74), not by any signal the run produced. Recorded because it is this repo's own [`Code_Quality_Standards.md`](../../Code_Quality_Standards.md) §4.0 failure mode reproduced **inside the tool built to hunt for it**: a process that reports a conclusion without recording what it measured is indistinguishable from one that measured everything. **Any future catalogue pass must assert its own coverage before its findings are read.** + +**Cluster:** Code quality / standards coverage. **Priority:** P3. **Verdict:** build (small) for the pass; **decline** for the measure. **Severity:** no product effect and no security effect — this is a coverage question about the gates, not a defect in them. + +**The decline, stated first so it stays decided. Three reasons, any one sufficient:** + +1. **No conformant measure is producible for this codebase.** There is no free or open-source ISO 5055-conformant Python analyser. The conformant ecosystem is C/C++/Java/C#/COBOL-weighted: Perforce names Helix QAC and Klocwork, neither of which analyses Python; Kiuwan analyses Python commercially, and conformance claims are language-scoped. A measure nobody here can compute cannot be a gate, a scorecard row, or a claim. +2. **No procurement pull.** 5055 exists to be **cited in a contract** — an outsourcer and a buyer writing "the delivered system shall score X" into a statement of work. MEFOR is open source distributed on PyPI; there is no contract counterparty for that clause. Health-system buyers ask for HIPAA mapping, SOC 2, HITRUST and ASVS. +3. **It collides with this project's own ratified rule.** [`Code_Quality_Standards.md`](../../Code_Quality_Standards.md) §4.1 forbids certifying quality on a single number, on adversarially-verified evidence. **Be fair to 5055 on this point:** counting *specific named severe weaknesses* is a materially better construct than the SonarQube severity buckets §2 refuted, so the collision is with the "our ASCQM Security score is N" framing, **not** with the weakness list itself. That distinction is the whole reason the catalogue survives the decline. + +**What is worth taking, and it costs nothing.** The OMG **ASCQM 1.1** specification (formal, July 2022) — which is the technical content ISO/IEC 5055:2021 carries — is downloadable from `omg.org/spec/ASCQM/` as a **non-member PDF plus a machine-readable XMI**. The ISO document does not need to be bought to read the weakness list. + +**Counts, with the unverified ones marked.** Confirmed from CISQ: **Security 74** (36 parent + 38 child), **Reliability 74** (35 + 39), **Maintainability 29**. **Performance Efficiency is widely quoted as 15, and the widely-quoted "139 total" likewise, and NEITHER was confirmed against a primary source** — do not restate either without checking the ASCQM PDF directly. They are recorded here as unverified precisely so the next reader does not launder them into a doc. + +**The work: one bounded pass, two questions per weakness.** *Could this occur in this codebase?* and *does any current check see it?* A no/no pair becomes a backlog item or a semgrep rule, and nothing else is produced. The high-yield slice is the **system-level** entries — weaknesses visible only across component boundaries and data flows. That is a real blind spot for a three-stage persisted pipeline with three store backends, and it is the one thing the catalogue offers that ruff, mypy, bandit, semgrep and CodeQL do not already cover between them. + +**Expect a high not-applicable rate, and do not read it as a result.** The Reliability and Security lists lean heavily on memory management, pointer arithmetic and buffer bounds. This is the same shape already measured against ASVS V10, where 25 of 27 cells were carried as not-applicable. A large n/a count is a fact about the language, not about the code. + +**Scope fence, and it is the load-bearing part of this item.** The output is items or rules. **Not** a fifth standards document, **not** a scorecard, **not** a gate, **not** a status row anywhere. The project already carries four standards documents, the ASVS scorecard, the HIPAA/800-66 mapping and the CISO register; each additional framework is another surface on which a claim can go stale, and this repo has already been bitten by exactly that — [`Code_Quality_Standards.md`](../../Code_Quality_Standards.md) §4.0 exists because three gates were green while measuring nothing. + +**Difficulty 3 is the judgment, not the reading.** The pass is mechanical; "does any current check see it" is the question that goes wrong. Answering it from a gate's *name* rather than from its *measured output and scope* is the §4.0 failure mode reproduced by hand. Every "covered" answer must name the check and state its scope — `jscpd` sees `messagefoundry/` only, the mutation gate sees one module, `testpaths` excludes the webconsole package (#1027). A coverage claim that does not name its instrument is not a coverage claim. + +**Related:** [`Code_Quality_Standards.md`](../../Code_Quality_Standards.md) §4.0 (gates that measure nothing) and §4.1 (the anti-metric rule), #1006 (a mutation that matches is not a mutation that bites — the same "the check ran" versus "the check bites" distinction), #1027 (a green that is not evidence about what it appears to cover), #1074 and #1075 (the SSDF half of the same question). + +**Source:** owner question 2026-08-06 — "is ISO/IEC 5055:2021 / OMG ASCQM 1.1 valuable, should we be applying it". Filed as the answer's actionable residue. The tool-support and procurement findings are from a research pass that day; the counts are as marked. + +--- + +## 1074. The SDS attestation posture does not record that the CISA self-attestation exempts freely-available OSS + +> ✅ **SHIPPED 2026-08-07 — the documentation is the whole deliverable.** Value **4/10** · Difficulty **1/10** · _quick win_. Two paragraphs added to [`Secure_Development_Standards.md`](../../Secure_Development_Standards.md) **§9** (see the citation correction below), plus the 800-218A guard placed in the AI companion's `Aligns to` row rather than its body — that row is where a reader would go to add the wrong anchor, so that is where the note has to be. Original filing follows. §9 states the software is *"self-attested as NIST SSDF-aligned"* and never said what that attestation is, and is not, answerable to. The CISA Secure Software Development Attestation Form explicitly **exempts software that is freely obtained and publicly available**. One missing sentence, and its absence invited an error in **either** direction. + +**CITATION CORRECTION, and it was wrong as filed.** This item said the attestation posture lives in **§6.3**. It does not: §6.3 is *OWASP ASVS 5.0 Level 3 — scope*, and the attestation posture is in **§9 Evidence and attestation**. The error came from matching the phrase without opening the section around it. Recorded rather than silently repaired because a wrong section pointer in a filed item is the same defect class the item itself is about — a claim nobody has checked and a claim nobody has noticed is wrong look identical until someone follows it. + +**Cluster:** Standards record / attestation honesty. **Priority:** P3. **Verdict:** build (small). **Severity:** no product effect and no security effect. The defect is in the record: a reader cannot tell from §6.3 whether the SSDF alignment discharges an obligation or volunteers evidence, and those imply different things about what may be claimed to a buyer. + +**The fact to record.** The CISA Secure Software Development Attestation Form (finalised 2024-03-11) does not require attestations for software that is freely obtained and publicly available, nor for open-source software obtained directly by a federal agency, nor for third-party open-source components incorporated into an end product. + +**Two consequences, pulling in opposite directions — which is exactly why it is one sentence and not a paragraph:** + +- MEFOR's SSDF alignment is **voluntary buyer evidence, never a regulatory obligation**. Nothing about it is owed to anyone today, and a doc that implies otherwise overstates the project's standing. +- **The exemption stops applying to a paid or hosted offering.** A commercial tier changes the analysis, and writing the condition down now is what makes that visible later instead of assumed. This is the more valuable half: the trap is a future reader inheriting an exemption whose precondition has quietly lapsed. + +**Absence is what invites the error, not any wrong sentence that is there today.** With nothing written, a later reader can equally well claim compliance value the project does not have, or assume an obligation that does not exist. Both are instances of the class [`../CLAUDE.md`](../../../CLAUDE.md) §11 names — a compensating control, or a claim, resting on a false premise. + +**Second half of the same edit: do not anchor the AI companion to SP 800-218A.** 800-218A is the **Generative AI profile** — practices for organisations *producing* AI models and dual-use foundation models. It is **not** about building software *with* an AI assistant, which is what [`Secure_AI_Development_Standards.md`](../../Secure_AI_Development_Standards.md) governs. **Verified 2026-08-06: nothing in `docs/` cites it.** Keep it that way and record *why*, so the next reader who notices an SSDF companion with "AI" in the title does not wire in a plausible-looking but wrong anchor. That companion currently has no NIST anchor, and it does not need a wrong one. + +**Difficulty 1.** Two sentences in SDS §6.3, one line in the AI companion. No code, no gate, no scorecard change. + +**Related:** #1075 (the other SSDF record item — that one is trigger-gated, this one is actionable now), #1053 (a document calling built things "planned" — the same class of defect, the record disagreeing with the facts), [`../CLAUDE.md`](../../../CLAUDE.md) §11. + +**Source:** owner question 2026-08-06 — "what about NIST SP 800-218 v1.1 (SSDF)". The answer was that SSDF is already adopted throughout the SDS; this is one of the two deltas that survived checking. + +--- + +## 1075. Re-map SDS section 4 when NIST SP 800-218r1 (SSDF 1.2) goes final + +> ✅ **CLOSED 2026-08-07 — NOT by doing the re-map, which remains correctly undone.** Value **3/10** · Difficulty **4/10**. Closed on the owner's reading, which was right: the SDS maps **SP 800-218 v1.1, and v1.1 is the current final version**, so this item described zero present work and zero present defect. A watch item for an event with no announced date is backlog noise. **Its one load-bearing sentence was not discarded — it was re-sited**, into [`Secure_Development_Standards.md`](../../Secure_Development_Standards.md) §9 alongside #1074, where the reader who would re-map against the draft is actually looking. A guard in the document beats a guard in the ledger. + +**DO NOT read this as "the SSDF 1.2 re-map is done."** It is not started and must not be started: SP 800-218r1 is still an Initial Public Draft (published 2025-12-17, comments closed 2026-01-30, no announced finalisation date). The trigger, the per-ID re-resolution rule, and the PW.7-deviation caveat now live in SDS §9. **If r1 goes Final, that is a new item** — do not reopen this one, because its number is closed and a reopened closed item is invisible to anyone reading the ledger for open work. + +**Cluster:** Standards record. **Priority:** P3. **Verdict:** build, **when triggered**. **Severity:** none today — the SDS is correct as it stands. + +**Status, verified against `csrc.nist.gov` on 2026-08-06.** SP 800-218r1 (SSDF Version 1.2) is an **Initial Public Draft**, released 2025-12-17; the comment period closed 2026-01-30; **no finalisation date has been announced**. SP 800-218 v1.1 (February 2022) remains the current final version. + +**Why the record is right as it stands.** [`Secure_Development_Standards.md`](../../Secure_Development_Standards.md) pins *"NIST SP 800-218 (SSDF)"* v1.1 in its `Aligns to` line, and §4 is organised by its four practice groups (PO / PS / PW / RV) with practice IDs cited natively — PS.2, PO.4, PW.1–PW.2, PW.7, PW.8. Every one of those resolves correctly against the current final standard. Nothing is stale; the item is a **watch**, not a repair. + +**The trigger.** SP 800-218r1 reaching **Final** status on `csrc.nist.gov`. Not a new draft, not a second comment period. + +**The blast radius, so the cost is visible before anyone starts.** Measured 2026-08-06: **143 SSDF references across 11 files** — the SDS itself, [`Secure_AI_Development_Standards.md`](../../Secure_AI_Development_Standards.md), [`Secure_Build_Standards.md`](../../Secure_Build_Standards.md), [`Secure_Build_Scorecard_MEFOR.md`](../../Secure_Build_Scorecard_MEFOR.md) (which *grades* under the practice groups, including the documented single-maintainer deviation for PW.7), [`Code_Quality_Standards.md`](../../Code_Quality_Standards.md) (which maps its signals to PW.7 / PW.8), plus scattered citations in `PHI.md`, `ARCHITECTURE.md`, ADR 0109, the master test plan, `.github/SECURITY.md` and the CHANGELOG. **That is the size of the change, not a to-do list** — several of those are prose mentions needing no edit at all, and treating the count as a checklist is how a re-map becomes a week. + +**Difficulty 4 is the ID churn, not the reading.** SSDF 1.2 renumbers and adds practices and tasks, so **a mechanical find-and-replace is exactly the wrong instrument**: a citation that still resolves to a real practice ID but a *different* practice is the failure that looks like success, and nothing in CI can see it. Every cited ID must be re-resolved against the new text by hand, and the single-maintainer deviation in the Secure Build scorecard has to be re-justified against whatever 1.2 says about review, not carried across on the assumption that PW.7 still means what it meant. + +**Do not act early.** Do not re-map against the draft, and do not track it incrementally as the draft changes — a draft that moves twice costs the re-map twice and can still land somewhere else. + +**Related:** #1074 (the same document's attestation posture — actionable now, unlike this), #1073 (the ISO 5055 half of the same question). + +**Source:** owner question 2026-08-06 — "what about NIST SP 800-218 v1.1 (SSDF)". Draft status re-verified directly against the CSRC publication page the same day rather than taken from a secondary summary. + +--- + +## 1081. released-line audit: detect an advisory against the latest release's pinned runtime + +> ✅ **Shipped 2026-08-07.** `released-line-audit` in `.github/workflows/security.yml` audits the **latest release tag's** `docker/locks/requirements-core.lock` on the existing daily cron, plus `workflow_dispatch` with a tag override. Advisory by placement (schedule/dispatch-only, so it can never report on a PR) but **not** `continue-on-error`: it goes red on a finding. `nightly-notice.yml` was extended to watch `Security` so a red scheduled run reports somewhere. + +**Cluster:** Supply chain / CI. **Priority:** P3. **Verdict:** built, reduced. **Severity:** low — there are zero deployments, so this closes a window before anyone is in it. + +**The gap, stated correctly — and it is NOT the one first claimed.** The original framing was *"nothing re-evaluates a published VEX against advisories disclosed after its tag"*, offered with CVE-2026-69247 as evidence. That framing is **wrong and was retracted**. The advisory was caught the day it published, by an existing required gate: commit `ac87246f` records *"pip-audit (a required gate) flagged cryptography 49.0.0 for CVE-2026-69247"*. Nor was `main` ahead of the tag — `git show ac87246f^:docker/locks/requirements-core.lock` and `git show v0.3.2:docker/locks/requirements-core.lock` both read `cryptography==49.0.0`. Detection was never missing. + +What was unwatched is the **release-lag window**: the interval between a fix landing on `main` and a release carrying it. `pip-audit` reads the checked-out tree, so on the daily cron it answers *"is what we would ship next current"*. That is a different question from *"does the version we already shipped carry a known advisory"*, and the two answers diverge for exactly the length of that window. + +**Residual scope, so nobody over-reads a red run.** This audits the **core runtime closure only** (`requirements-core.lock`), which is what the shipped CycloneDX SBOM inventories. A wheel adopter resolves against `pyproject.toml`'s floors (`cryptography>=48.0.1`), and no container image is published at release, so a finding here is a statement about **the published SBOM's inventory**, not about every install. Extras and the CI toolchain stay covered against `main` by the `pip-audit` job. + +**Pre-merge self-tests (all four passed; the instrument was proven able to see the class).** Against `v0.3.2`'s lock `pip-audit` exits 1 naming `PYSEC-2026-3552` on `cryptography 49.0.0`; against `origin/main`'s lock it exits 0 — so it distinguishes the two states rather than only ever reddening. An empty lock reads 0 pinned requirements against a floor of 25, hitting the fail-closed path. The tag selector returns exactly `v0.3.2` and excludes `webconsole-v0.2.15`. The positive control is durable: the vulnerable lock lives in git history, so `workflow_dispatch` with `released_line_audit_tag=v0.3.2` re-arms it forever. + +**Deliberately NOT built, with reasons — this is the part worth not re-litigating.** + +1. **Scanning the published `messagefoundry-sbom.cdx.json` asset with trivy.** The SBOM is generated by installing the core lock into a clean venv, so the lock **is** the population. Scanning the asset answers the same question plus *"did the generator inventory it correctly"* — a real but different defect — at the cost of a second pinned scanner, a second vulnerability database, and divergence risk against the operator-facing command in `docs/SUPPLY-CHAIN.md`. +2. **Applying any VEX to this gate.** Refuted during design and the reason is subtle: `security/vex/README.md`'s own worked example names the product with **no version qualifier**, so a `fixed` or `not_affected` statement written on `main` would suppress the finding against the already-shipped release. The gate would turn green the moment the assessment was written, before any release carried the fix. `--ignore-vuln ` is the escape hatch — explicit, per-advisory, greppable. +3. **A merge-blocking VEX linter.** As specified it would reject `security/vex/README.md`'s own example and mandate a non-OpenVEX field in a document shipped to hospital scanners. There are zero statements today, so there is nothing to lint. +4. **A release-time VEX version-bump gate.** Real (nothing enforces the documented bump), but with no statements at `version: 1` across every release so far there is no violation and no way to exercise the failing shape. +5. **An in-job issue filer.** Two notifiers for one failure. Extending `nightly-notice.yml` covers every scheduled `Security` job, not only this one. +6. **A `release: published` trigger.** `release.yml` creates the release and uploads assets in one call, so a `published`-triggered run can race the upload. The daily cron bounds detection at ~24h. + +**Also deferred:** the `docs/SUPPLY-CHAIN.md` half of this change — a sentence scoping *"continuously audited by pip-audit"* to `main`'s lockfiles, and the `releases/latest/download/...` permanent fetch URLs. Held back only because PR #264 edits the same file and stacking the two would risk a conflict; land it once #264 merges. + +**Related:** #1079 (the same workflow's header denying a trigger its `on:` block declares), ADR 0149 (the SBOM/VEX program this sits beside — unchanged, and it needs no amendment). + +**Source:** found 2026-08-06 while auditing the shipped v0.3.2 release assets. The original design was refuted 3 of 3 by adversarial review and rebuilt at roughly one tenth the size; the retained design record is in the vault. + +--- + +## 1094. CLAUDE.md §12 decline markers cite the live backlog file for items that have archived + +> ✅ **Closed 2026-08-07 — already satisfied when filed; no work was performed under this number.** The repoint this item asks for merged as `befe997e` (PR #271) **one commit before this item itself landed** (`7ecff8ae`, PR #272). Re-verified on `origin/main` after both: §12 now reads *"BACKLOG #26 — closed, so it lives in [`docs/archive/backlog/BACKLOG-CLOSED.md`](../../archive/backlog/BACKLOG-CLOSED.md), not in the live ledger"*, and the same for `#27`. The finding below was true when measured and stale by the time it was recorded — a filing race, not a wrong observation. Original scoring, for the record: Value **4/10** · Difficulty **1/10** · _quick win_. +> +> ⚠️ **The two markers named here were the whole scope, and they are only two instances of a much larger class.** A repo-wide sweep the same day found **at least 90** further path-bearing citations naming `docs/BACKLOG.md` for an item that lives in the archive, plus broken relative hrefs and stale line anchors. That breadth is **#1095**, which also carries the detectability argument below at its true scale. Closing this number does not close that. + +§12's **Don't** list is where a decline is lifted so it **outlives the backlog item that recorded it**. Two of its markers cited [`docs/BACKLOG.md`](../../BACKLOG.md) `#26` and `#27` — and retiring an item **moves it verbatim** into [`archive/backlog/BACKLOG-CLOSED.md`](../../archive/backlog/BACKLOG-CLOSED.md). Measured on `origin/main` 2026-08-07: `## 26.` and `## 27.` were **absent** from `docs/BACKLOG.md` and **present** in the archive. Both pointers were dead until `befe997e` repointed them. + +**Cluster:** Documentation record / instrument accuracy. **Priority:** P3. **Verdict:** build (trivial) — superseded by the fix having already landed. **Severity:** no product effect and no security effect. The decline text itself was intact and still binding throughout; only the route back to its reasoning was broken. + +**Nothing in this repository can catch this class today, and that is the argument for whatever check is proposed.** The markdown link resolves perfectly — it points at `docs/BACKLOG.md`, which exists — so a link checker cannot fire. The part that goes stale is the **human-readable number beside the link**, which no tool reads. That is why two instances sat unnoticed rather than being caught by the gates this repo already runs. A check that only validates link targets will report this file clean forever. + +**The repo already has the correct form, at scale.** `docs/BACKLOG.md` carries **44** citations shaped `[#52](archive/backlog/BACKLOG-CLOSED.md#52-corepoint-capability-parity-gaps--prioritized-roadmap-input-2026-06-27)`, and [`AOAG-DEPLOYMENT.md`](../../AOAG-DEPLOYMENT.md) does the same for `#100`/`#101`. So this is a §12 omission, not a missing convention. + +**Fix shape.** Repoint the `#26` and `#27` markers at the archive. **Do not hand-write the fragment** — a pointer with a wrong fragment is worse than one with none, because it looks precise and lands nowhere. Derive it from the item's own heading text under GitHub's slug rule, or cite the file without a fragment. A marker that must outlive its item is best served naming **both** locations (live now, archive after), which is what [`../CLAUDE.md`](../../../CLAUDE.md) §12's ISO 5055 marker was corrected to do. + +> **CORRECTION 2026-08-10 (BACKLOG #1099) — the sentence above named tooling that does not exist.** +> It originally read *"the archival pass generates the anchor … Either derive it from the generator or +> cite the file without a fragment."* **There is no archival tooling in this repository**: closing an +> item is a manual move of its text from this file into +> [`archive/backlog/BACKLOG-CLOSED.md`](../../archive/backlog/BACKLOG-CLOSED.md), and the fragment is +> GitHub's heading slug, which nothing here generates. +> [`tests/test_link_resolution.py`](../../../tests/test_link_resolution.py) states the same thing from the +> other side — *"The move is manual — no script performs it — so there is nothing to fix upstream"* — +> and draws the conclusion this sentence pointed away from: a guard at the moment the item lands is +> the only thing that can catch the rot, because there is no generator to fix. Corrected rather than +> rewritten silently, since the block around it is closed record. + +**The near-miss is the reason this is worth a number.** The 5055 decline marker filed under #1073 cited only the live file, and would have rotted identically the moment #1073 archived — caught in review before merge. The session that wrote it had **already noticed** the #26/#27 staleness earlier that day and judged it not worth chasing, then reproduced it in a marker whose entire purpose is to outlive its item. A rot consciously declined is one you have stopped seeing well enough to avoid repeating. + +**Related:** #1073 (the decline whose marker nearly repeated this), #1000 (a control whose green is not evidence about what it appears to cover), #1087 and #1063 (the same cluster — an instrument answering a narrower question than the one asked), [`../CLAUDE.md`](../../../CLAUDE.md) §11 (state a load-bearing fact once and link to it — which is what makes the link's durability load-bearing). + +**Source:** found 2026-08-07 while verifying #1073's §12 marker against `origin/main` for HANDOFF-1073 item B5. The `#26`/`#27` absence was measured in both files rather than inferred, and the 44-occurrence convention count was re-run without a head limit after a first reading of "~18" turned out to be an artifact of the truncated output. + +--- + +## 1099. BACKLOG #1094 describes an archival pass that generates anchors; no archival tooling exists + +> ✅ **Closed 2026-08-10 — the sentence is corrected in place, and the absence was re-confirmed by search rather than inherited.** Value **4/10** · Difficulty **1/10**. #1094's *"the archival pass generates the anchor … derive it from the generator"* now carries a dated `CORRECTION` blockquote naming the manual move and GitHub's heading slug, left as a marked correction rather than a silent rewrite because the block is closed record. Filed 2026-08-07. + +**Cluster:** Documentation record / instrument accuracy. **Priority:** P3. **Verdict:** build (a +prose correction). **Severity:** no product effect. + +**Why this is not pedantry.** The sentence points maintenance at the wrong place: it implies the fix +for anchor rot belongs in a tool, when the only thing that can catch it is a gate at the moment the +item lands - which is exactly the reasoning [`tests/test_link_resolution.py`](../../../tests/test_link_resolution.py) +records. A future reader looking for the generator to fix will not find one. + +**The absence, re-measured 2026-08-10 rather than quoted.** `git ls-files | grep -iE archiv` returns +**seven** paths and every one is a document — `docs/archive/backlog/BACKLOG-CLOSED.md` and six under +`docs/archive/throughput/`. No script, no CI job, no hook. The independent corroboration is the gate's +own docstring: *"The move is manual — no script performs it — so there is nothing to fix upstream."* + +**TWO CORRECTIONS TO THIS ITEM'S OWN TEXT, both the class it was filed about.** + +- It said the sentence *"now sits in the archive"*. It did not — #1094 was closed-in-live, still in + [`BACKLOG.md`](../../BACKLOG.md), and the correction was therefore applied there. It travels into the + archive with #1094's block in the same change. +- It cited `tests/test_archive_link_resolution.py`, **which is on no merged ref.** PR #281 squash-merged + as `6cb34f5f` and the file landed as `tests/test_link_resolution.py`; the pre-squash name survives + only on the stale local branch `refs/heads/pr281`. An item about a citation naming a thing that does + not exist cited a thing that does not exist. Found by searching every ref, not by trusting the string + — the same instrument the file's own Ledger erratum prescribes. The identical stale name in #1095's + block was corrected with it. + +**Related:** #1094, #1095 (the repo-scale instance of the same class), #1000. + +**Source:** found 2026-08-07 while resolving the #1095 anchor classes; the absence of archival +tooling was confirmed by looking for it, not assumed. + +--- + +## 1101. the connscale empty_claims_monotonic SLO reports runner contention as an engine defect + +> ✅ **SHIPPED 2026-08-08 - the SLO now reads empty claims PER MESSAGE, and the latent `claim_mode` grouping defect went with it.** The asserted metric is `empty_claims_per_msg`, computed as the ratio of two rates taken over the SAME first-to-last in-hold samples, so the span cancels algebraically and the quantity is exactly `Δempty_claims / Δread` -- there is no wall clock left for runner contention or a mid-hold reload stall to move. `_monotonic_slo` now groups by `(sweep_mode, claim_mode)` rather than `sweep_mode` alone, so a profile combining `per_lane` and `pooled` can no longer chain-compare across claim modes. The per-second numbers are retained in the report as the operator-facing figures; they are simply no longer what gates a merge. **Verified against the failure mode, not just for green:** eight tests pin the invariance property AND the still-detects-a-real-regression property together, and both were shown to go RED under mutation - reverting the grouping fails 3, restoring the per-second metric fails 2. A metric that never fires would have passed a stability test alone, which is why the two are pinned as a pair. **Not done, and deliberately:** gating `reload_seconds` directly was raised below as a conditional ("if that cost is worth gating") and is a separate judgement, not part of this fix. Original filing follows. Value **4/10** · Difficulty **2/10**. +> `tests/test_connscale_smoke.py:170` asserts `empty_claims_monotonic`: the N=24 empty-claim **rate per +> second** must be at least 0.75x the N=12 rate. The metric has wall-clock in its denominator and a +> deliberately un-gated O(N) probe in its numerator's way, so **CPU contention alone flips it red with +> no engine change**. It reds PRs that touch nothing it measures. + +**Cluster:** Developer Experience & CI. **Priority:** P2. **Verdict:** build. **Severity:** no product +effect, no PHI effect. The cost is queue throughput: a spurious red on a shared runner costs a full CI +cycle per occurrence, and the queue pays it on PRs with no engine content at all. + +**Value 4, not 6.** The ladder caps Developer Experience & CI at 4, and the workaround is real and +cheap - re-run the leg. It is not a production blind spot and touches no shipped default. + +**The observation.** `#281`'s `test (windows-2025, py3.14)` leg, run `31226408247`: + +``` +FAILED tests/test_connscale_smoke.py::test_connscale_smoke_end_to_end +AssertionError: fixed_aggregate@N=24: 255.9 < prior 435.4 * 0.75 ratio 0.588 +1 failed, 10764 passed, 830 skipped in 1849.42s (0:30:49) +``` + +**This is NOT #1096.** The `Tests (pytest)` STEP ran 30:59 against the 36:00 cap and **failed** rather +than being killed. #1096 is the cap; this is an assertion failing well beneath it. `ci.yml` already +records sessions substituting the job reading for the step reading while triaging that leg - reading the +job here gives ~32 minutes and invites the wrong cause. + +**Reproduced locally by adding CPU contention and nothing else** - same commit, same box, same config: + +``` +CI (contended runner) 0.588 +local replicate 1 0.674 +local replicate 2 0.451 +local replicate 3 0.590 +local replicate 4 (PASS) 2.49 +``` + +A 0.75 threshold cannot discriminate inside a 0.451-2.49 spread. **The gate is a coin flip under load, +not a detector.** + +**Mechanism.** The reload probe fires at `hold*0.5` (`harness/load/connscale/runner.py:384-385`) while +the sampler is still running (`:389`), and performs a serial O(N) quiesce-and-swap under `_reload_lock` +(`messagefoundry/pipeline/wiring_runner.py:3518-3545`). Measured under contention: **0.124s at N=12, +3.63s at N=24** - longer than the entire 1.5s hold. It halts the commits that drive `wake_fanout`, +which is roughly 90% of the metric's numerator. Disabling `reload_probe` under identical contention +flips the result to a pass (274.6 -> 282.8, ratio 1.03). + +**The sharp part: the corrupting probe is already exempt from assertion.** +`tests/test_connscale_smoke.py:182-188` exempts that same reload probe from per-step assertion, in its +own words *"stricter than the probe's own contract and flakes on slow CI runners"*. Its O(N) cost is +nevertheless loaded in full onto `empty_claims_monotonic`, which **is** asserted per step. The suite +declined to gate a cost and then gated a high-variance proxy for it. + +**The engine was correct in the failing arm.** `no_loss` asserts at `:162-164`, **before** the SLO at +`:170`, and the reported failure is the `:170` message - so at N=24 every message was received, +delivered and drained. In the local reproduction the N=24 arm was **better** on what matters: drain +0.801 -> 0.575s, achieved_read 4.53 -> 5.38/s over the identical window. + +**`fd_count_monotonic` is not a control for this.** `handles_peak = max(handles)` +(`harness/load/connscale/runner.py:963`) is a peak **count** with no time denominator, so it is +structurally immune to the time dilation that is the entire question. Its passing proves 24 sockets +opened and nothing else. Do not read it as evidence the arm was healthy. + +**Why it surfaces now.** `#1014` removed `@pytest.mark.flaky(reruns=2)` from this test (commit +`1d988fdc`) so that a genuine cross-worktree port collision would surface red instead of self-healing. +That change is correct. The side effect is that runner-variance failures in the same test also surface +red, where the retry used to absorb them - and this test's own comment at `:166` already describes the +SLO as *"a LOOSE >= per mode; CI runners are noisy"*. The absorber was removed without adjusting the +assertion it was absorbing for. + +**The fix: assert empty claims PER MESSAGE, not per second.** Under `fixed_aggregate`, `sent` is +constant across N (36 at both, measured), so per-message is exactly the per-commit herd size that the +mode's own docstring (`harness/load/connscale/profile.py:9-11`) says it exists to measure, and it is +immune to wall clock. Healthy local readings: **39.1 at N=12, 77.8 at N=24** - a clean 2.0x against a +0.75 floor. If the O(N) reload cost is worth gating, **gate `reload_seconds` directly** rather than +through an empty-claim rate. + +**Two mechanisms that look right and are WRONG.** Recorded so they are not re-derived: + +* *"255.9 is below the 288/s do-nothing floor, therefore impossible."* Inverted. `3N/poll_interval` is + a **ceiling** on the idle component, not a floor on the total - a woken worker is preempted and never + books an idle timeout. Measured idle ran at 38% of that number on a healthy box. +* *"the wall-clock window dilates with N."* Not the operative term. Spans measured 2.646 vs 2.649s + unloaded and 6.85 vs 6.51s contended - essentially equal at both N. In the failing runs the + **numerator collapsed**; the denominator did not grow. + +The conclusion survives both; those two arguments do not. + +**LATENT, dormant today, worth fixing in the same pass.** `_monotonic_slo` groups only by `sweep_mode` +(`harness/load/connscale/runner.py:1084-1086`) and chains `prev_val` across the count-sorted group. A +profile setting `claim_modes = ["per_lane","pooled"]` with `empty_claims_monotonic = true` would +chain-compare **across claim modes**, and `harness/load/connscale/compare.py:22-25` states that +pooled's empty-claim rate **should** be materially lower. No shipped profile combines them, so this +cannot fire today - but the grouping is wrong independently of the metric change above. + +**Related:** #1096 (the same leg, a different and genuinely distinct cause - do not merge the two +stories), #1014 (removed the retry that had been absorbing this class), #1000 (a control green because +its evidence could not see the class it covered - `fd_count_monotonic` here is the same shape). + +**Source:** found 2026-08-07 when `#281`'s windows-2025 leg failed on a docs-and-link-checker diff with +no engine content. Causal exclusion first (the diff reaches no engine code; the suite is serial per +`pyproject.toml` `addopts`, so the PR's new test collects **after** `test_connscale_smoke.py` and had +not run), then reproduced under contention rather than argued. The investigating session retracted two +of its own mechanisms, above, before the conclusion was accepted. + +--- + +## 1104. the DATABASE connector never closes its cursors, so a pooled connection is returned busy and the source's mark fails, emitting a duplicate + +> ✅ **SHIPPED 2026-08-08 — found and fixed in the same pass; reproduced against a real SQL Server 2022 container, not inferred.** Value **6/10** · Difficulty **2/10**. `messagefoundry/transports/database.py` opened a cursor at **five** sites and closed it at **none** — `cur.close()` appeared nowhere in the file. aioodbc/pyodbc keep the ODBC statement handle open until the cursor is closed, so every one of those connections went back to the pool **busy**, and the next caller's first command failed with `HY000 Connection is busy with results for another command`. **This is delivery semantics, not tidiness:** the usual victim is the DATABASE source's `mark`, and `_poll_once` treats a failed mark as at-least-once — the row is left unmarked and **re-emitted as a DUPLICATE**. + +**Cluster:** Connectors / delivery semantics. **Priority:** P2. **Verdict:** built. **Severity:** no PHI +effect. A shipped connector emits duplicate messages on SQL Server whenever the pool hands back a dirty +connection at the wrong moment. Per CLAUDE.md §0 this is stated in the conditional: **a deploying site +running a DATABASE source against SQL Server would see duplicates**, at a rate set by pool reuse. + +**Observed on `main`**, not on a branch: + +``` +DATABASE source mark failed (row will re-emit, a duplicate): + ('HY000', '[Microsoft][ODBC Driver 18 for SQL Server]Connection is busy with + results for another command (0) (SQLExecDirectW)') +FAILED tests/test_database_source_integration.py::test_source_polls_and_marks_rows +assert [(1, 1)] == [(0, 2)] # 1 row left unmarked -> it re-emits +``` + +**Mechanism.** `_select` runs the poll and `_mark` runs an `UPDATE`; both release the connection in a +`finally` without closing the cursor. An `UPDATE` leaves a row count pending on the statement handle, +so the connection is dirty when it returns to the pool. The failure then lands on **whatever statement +next draws that connection**, which is why it reads as unrelated and intermittent. + +**⚠️ THE ERROR APPEARS ON THE INNOCENT STATEMENT.** The command that fails is not the one that left the +handle open. Triaging the reported statement leads nowhere; the cause is one connection-checkout +earlier. That misdirection is the whole reason this survived. + +**Why CI never caught it on `main`.** The `sql server (store + connector)` leg is gated on server-DB and +docker path changes, so it is **skipped on every `main` push** — measured across the five most recent. +It runs only on PRs that touch those paths, which is how a real defect sat on `main` while the leg that +detects it stayed green-by-absence. That is the #1000 shape at the workflow level: a check whose silence +is mistaken for a pass. **Filing this does not fix that**; the leg's `main` coverage is a separate +question and is NOT addressed here. + +**The fix.** A `_close_cursor` helper, called before `pool.release` at all five sites. It never raises: +a close failure must not mask the caller's real error, and must not skip the release that follows — +leaking a pooled connection to save a cursor is the worse trade. + +**⚠️ BE HONEST ABOUT THE INTEGRATION EVIDENCE — IT IS WEAK ON ITS OWN.** Measured on the container: +**1 failure in 10 runs** on the unfixed tree, **0 in 10** with the fix. At a ~10% base rate that +difference is **well inside chance** and proves nothing by itself. It is recorded as the reproduction +that found the defect, not as the evidence that it is fixed. The evidence is +`tests/test_database_cursor_close.py`, which asserts the ordering **deterministically** against a fake +pool and was **verified to go RED on a mutant** with the closes removed (2 of 3 tests failed; the third +covers `_close_cursor`'s own contract and correctly did not). A guard with a 10% detection rate is not +a guard. + +**Related:** #1000 (a control green because its evidence could not see the class it covered — both the +skipped CI leg and the racy integration test are that shape), #1103 (found the same day, also a harness +/ connector defect whose error message points away from the cause), ADR 0003 (the aioodbc choice this +rides on). + +**Source:** found 2026-08-08 while triaging PR #253's red SQL Server leg. #253 was exonerated **by +measurement** — the same test fails identically on `main` — after first being exonerated by mechanism +(that step runs an explicit path list, so `testpaths` cannot reach it). The two reds on #253 were two +*different* unrelated failures, which is why "it failed twice, so it is real" would have been the wrong +read. Verified against a Docker SQL Server 2022 container after first confirming the host actually +reaches the container and not the native `MSSQLSERVER` service also running on that box: both listeners +on 1433 were Docker processes, and `SERVERPROPERTY('MachineName')` returned the container's own +hostname. That check is not optional on this machine. + +--- + +## 1200. the CI docs-only detector exempts EXECUTABLE files under `docs/` from the entire suite + +> ✅ **CLOSED 2026-08-10 — confirmed by reading the shipped workflow, not the commit message.** `.github/workflows/ci.yml` carries `alwayscode='\.(py|ps1|sh|ts|js|yml|yaml|toml|lock|cfg|ini)$'` and evaluates it in the FIRST `elif`, ahead of both `alwayscodepath` and `noncode`. Re-driven 2026-08-10 with the regexes read back out of `ci.yml`: `docs/security/asvs-apply-cells.py` -> code, `docs/SECURITY.md` -> NON-CODE, `.gitignore` -> code. `tests/test_ci_docs_only_detector.py` 23 passed. Filed 2026-08-09 - FIXED in the same change. Value **7/10** · Difficulty **2/10**. `ci.yml`'s `changes` job short-circuits the required `test` legs when every changed path is docs-only. `^docs/` is an alternation branch in that allowlist, so it matches a **`.py` under `docs/`** and short-circuits before the stated `*.py` rule is ever reached. A PR touching only such a file set `code=false` and skipped install, lint, type-check and the whole of pytest. + +**Cluster:** CI correctness / gate blindness. **Priority:** P2. **Verdict:** build (done). +**Severity:** no product effect and no PHI effect. The cost is that a defect here does not fail loudly +- it REMOVES the thing that would have failed, which is the worst failure mode a gate has. + +**Measured, not reasoned.** Extracting the live regex from `ci.yml` and running real `grep -E`: + +``` +PRE-FIX (noncode only): + docs/security/asvs-apply-cells.py -> NON-CODE (suite skipped) + docs/benchmarks/.../b5_microbench.py -> NON-CODE (suite skipped) +POST-FIX (alwayscode checked first): + docs/security/asvs-apply-cells.py -> code + docs/SECURITY.md -> NON-CODE (still short-circuits) + .gitignore -> code (via the noncode branch, BACKLOG #327) +``` + +**Blast radius.** Engine: 2 files, both benchmark scripts under +`docs/benchmarks/results/2026-07-04-adr0071-b5-executor-marshaling/` - low risk. Vault: 3 files, +including `docs/security/asvs-apply-cells.py`, the tool that WRITES the ASVS record of record and can +silently un-close an owner-closed cell. **Two mypy errors had been sitting in that file since it was +written; they could not have survived a single check.** That is the corroboration that the exemption +was real and not theoretical. + +**TWO THINGS MAKE THIS WORSE THAN A MISSING TEST.** + +**The comment and the regex disagree, and the comment is what people read.** `ci.yml` states the intent +in as many words: *"Anything outside the allowlist - any `*.py`, `ide/**`, config, lockfiles, OTHER +workflows, scripts, samples, harness - counts as CODE and runs the full suite."* The regex does not +implement that sentence. An auditor reads the comment, agrees with it, and moves on. + +**The precedent sits four lines above the defect.** `#327` fixed exactly this shape for `.gitignore` - +allowlisted as docs-only, so a `.gitignore`-only PR skipped `tests/test_private_paths_stay_ignored.py`, +*"the one guard that would catch the rule being deleted DID NOT RUN, on exactly the PR shape it exists +to catch"* - and the lesson was written down in place. The identical defect for `docs/**/*.py` was in +the regex immediately below that paragraph. **The instance was fixed and the class was left open, with +the reasoning that would have closed it preserved alongside.** That is the recurring shape: a fix that +does not generalise is the one that comes back. + +**The fix.** An `alwayscode` EXTENSION check evaluated BEFORE the `noncode` allowlist: +`\.(py|ps1|sh|ts|js|yml|yaml|toml|lock|cfg|ini)$`. An executable file is code wherever it lives. The +docs-only optimisation is deliberately preserved for actual documents - simply deleting `^docs/` would +have run the full suite on every prose edit, which is the cost the short-circuit exists to avoid. + +**The test drives the DETECTOR, and reads its regexes OUT of `ci.yml`.** A test carrying its own copy +of the pattern passes forever while the workflow drifts underneath it, reproducing this very defect one +level up. It asserts the regression in BOTH directions in a single test - the pre-fix logic classifies +`docs/x.py` as non-code AND the post-fix logic does not - because asserting only the new behaviour +cannot distinguish a fixed detector from a deleted one (`return True` passes that). It carries a +negative control, so a regex that accidentally matched everything cannot make every assertion pass +vacuously. + +**Source:** found 2026-08-09 while promoting the ASVS writer out of `docs/security/` (BACKLOG #1200's +sibling work), and escalated from instance to class by the parallel `asvs-tracking-rework` session, +which measured the blast radius in both repos and identified the `#327` precedent. + +--- + +## 1201. `redacted_settings` served credential-bearing HTTP headers outside a five-name list + +> ✅ **CLOSED 2026-08-10 — confirmed in the shipped code, not from the report.** `messagefoundry/config/wiring.py` `_is_secret_header()` now ends `return any(tok in low for tok in _SECRET_HEADER_SUBSTRINGS)` over `auth|token|secret|credential|password|passphrase|key`, with `_SECRET_HEADER_NAMES` kept as an explicit floor (`cookie` matches no substring rule), a `_NOT_SECRET_HEADER_SUFFIXES` exclusion, and a second VALUE arm (`_looks_like_a_credential_value`: RFC 7235 scheme prefixes + JWT shape) for opaque vendor names. `tests/test_connection_factory_redaction_domain.py` 58 passed. **The route-onward below is NOT closed by this** - see the residual. Filed 2026-08-09 - FIXED IN THE SAME CHANGE, and the entry is published WITH the fix rather than ahead of it. Value **8/10** · Difficulty **2/10**. Header redaction was `str(k).lower() in _SECRET_HEADER_NAMES` -- an exact-membership test against **five** strings (`authorization`, `proxy-authorization`, `x-api-key`, `api-key`, `cookie`). Header names are **operator-authored free text**, typed into `connections.toml` or a Handler, so an exhaustive list cannot exist even in principle. Measured against the shipped list: `X-Auth-Token`, `X-Amz-Security-Token` and `Private-Token` were all returned VERBATIM. + +**Cluster:** Security / secret disclosure. **Priority:** P1. **Verdict:** build (done). +**Severity:** on a first deployment, an operator who configured an outbound connection with a bearer +credential in any header outside those five would have had it returned by +`GET /connections/{name}/metadata` to any caller holding `Permission.MONITORING_READ`, and printed by +`graph --json` to stdout, a CI log and the IDE graph view. No PHI. Conditional, per the not-deployed +posture -- but the exposure needs no deployment to be *published*, which is why this entry ships with +its fix. + +**Measured before and after, both serializers:** + +``` +BEFORE: X-Auth-Token, X-Amz-Security-Token, Private-Token -> value returned verbatim +AFTER : all redacted to *** on redacted_settings AND display_settings +KEPT : Content-Type, Accept, User-Agent, X-Correlation-Id, X-Request-Id, + X-Forwarded-For, X-Api-Version, Idempotency-Key -> still readable +``` + +**This is `#1106` one surface over, and structurally worse.** `#1106` was a settings key that a factory +renamed across the parameter/setting boundary; settings keys at least come from function signatures and +are therefore *enumerable*. Header names come from an operator's keyboard. A listed domain was never +going to cover them, so the test is now by SHAPE -- a substring rule over +`auth|token|secret|credential|password|passphrase|key` -- with the original five kept as an explicit +floor, because `cookie` matches no substring rule and must stay named. + +**Erring toward redaction, deliberately, with the cost stated.** A false positive costs an operator one +masked value in a diagnostic view and one line in the not-a-secret list. A false negative serves a +bearer credential to a monitoring reader. The asymmetry is not close. Two exclusions keep the +diagnostic view usable: a suffix rule (`-id`, `-url`, `-uri`, `-name`, `-type`, `-version`, `-agent`, +`-for`), because an `-id` NAMES something rather than being it; and an exact list for +`Idempotency-Key`, which carries "key", is a client-generated request identifier, and is published in +the API docs of every service that uses it. + +**Found by generalising the `#1106` guard rather than by a report.** `#1106`'s fix added a test that +enumerates the redaction DOMAIN by AST and executes the real redactor against every member. The obvious +next question -- "does the sibling control have the same shape?" -- took one probe. That is the whole +method: the defect class is *a control whose domain is narrower than its surface*, and the way you find +the next instance is to ask which other control quantifies over a domain it does not derive. +`tests/test_connection_factory_redaction_domain.py` now covers both. + +**Route onward, NOT closed by this — PENDING OWNER LEDGER DECISION (G28).** The shape rule is a heuristic +over a free-text domain, so it is a floor and not a proof: a header named without any of those substrings +(`X-Shared-Signature`, a vendor-specific opaque name) still passes the NAME arm. The durable fix is for the +header value to never reach a serializer resolved -- the `env()`-only treatment `body_secret_value_*` +already gets -- and that is a larger change than this one. + +> **Residual carried forward 2026-08-10, deliberately un-numbered.** Closing this item closes the +> five-name membership defect; it does **not** close the route-onward above. Whether that residual becomes +> its own backlog number, folds into #1206's sibling residual (both are the same *"nested/free-text values +> are never `env()`-resolved"* shape), or is accepted as-is **is the owner's call, not the archiver's** — +> so no number was allocated for it here. The mitigation actually shipped is the second (VALUE) arm of +> `_is_secret_header`, which catches an opaque-named header carrying a `Bearer`/`Basic`/JWT value; a header +> both opaquely named *and* opaquely valued remains outside both arms by construction. + +**Source:** found 2026-08-09 while probing for a second instance of the `#1106` class before building a +generalised check, on the reasoning that a meta-check built from one instance is shaped like that +instance. Two domains were probed; this one leaked. + +--- + +## 1206. `redacted_settings` served ODBC driver credentials sitting in `odbc_params` + +> ✅ **CLOSED 2026-08-10 — confirmed in the shipped code, not from the report.** `messagefoundry/config/wiring.py` `redacted_settings()` now carries an `elif name == "odbc_params" and isinstance(value, dict)` arm emitting `{k: ("***" if _is_secret_odbc_key(k) else v) ...}`, and `_is_secret_odbc_key()` is shape-based and case-insensitive over `pwd|password|passwd|secret|token|credential|passphrase`, with `_NOT_SECRET_ODBC_KEYS` keeping the libpq PATH keywords (`sslkey`/`sslcert`/`sslrootcert`/`sslcrl`) readable. `display_settings` inherits it by delegation. **This is a DISPLAY fix; the storage residual is NOT closed** - see below. Filed 2026-08-09 - FIXED IN THE SAME CHANGE, entry published WITH the fix. Value **8/10** · Difficulty **3/10**. `redacted_settings` masks flat scalars and descended into `headers` alone, so a credential inside `odbc_params` was returned VERBATIM by `GET /connections/{name}/metadata` behind `MONITORING_READ` and printed by `graph --json` - on the SAME object whose top-level `password` masked correctly. + +**Cluster:** Security / secret disclosure. **Priority:** P1. **Verdict:** build (done). +**Severity:** on a first deployment, an ODBC driver password would be served to any monitoring reader +and written to stdout, a CI log and the IDE graph view. No PHI. + +**Measured, both serializers, before and after:** + +``` +BEFORE: odbc_params={"PWD": S, "sslpassword": S} -> both returned verbatim + password="p" on the same object -> '***' +AFTER : PWD, sslpassword, Password -> '***' + Encrypt, ApplicationIntent, + TrustServerCertificate, sslkey (a PATH) -> still readable +``` + +**IT IS NOT MERELY OPERATOR MISUSE, WHICH IS WHY IT MASKS RATHER THAN WARNS.** The docstring says +`odbc_params` "carries only static driver keywords", and the typed fields carry exactly ONE credential +(`username`/`password`, key names configurable via `odbc_user_key`/`odbc_password_key`). But +`_reject_envref_odbc_params` refuses `env()` there. So a connection needing a SECOND driver credential +- libpq `sslpassword` beside `PWD` - has no typed home and no `env()` form, and the inline literal is +the only expressible shape. **A refusal that removes the SAFE expression while leaving the UNSAFE one +is not a mitigation.** + +**THIS IS A DISPLAY FIX, NOT A STORAGE FIX — and the storage half is PENDING OWNER LEDGER DECISION +(G28).** Stated because the difference matters and is easy to lose. The credential remains an inline +literal in the config file. Keeping it out of the file needs `env()` to work here, which needs nested +settings to be env-resolved. That changes the resolution path and what `_reject_envref_odbc_params` +means, so it is the **route-onward** and is deliberately not folded in. + +> **Residual carried forward 2026-08-10, deliberately un-numbered.** `env()` resolution inside nested +> settings is the sibling of #1201's route-onward — the same *"a value inside a container is never +> `env()`-resolved, so the safe expression does not exist there"* shape, which is why they are named +> together rather than separately. Whether this earns its own number, merges with #1201's, or is accepted +> is the **owner's decision**; no number was allocated for it here, and it is not being quietly closed as +> prose. What IS closed is the disclosure: on a first deployment the value would no longer reach +> `/metadata` or `graph --json`. + +**A THIRD PREDICATE, AND THE FIRST ATTEMPT PROVES WHY.** I reached for `_is_secret_setting` - and it +returns False for every one of `PWD`, `Password` and `sslpassword`, because it matches a fixed +frozenset of MessageFoundry SETTINGS names while these are ODBC DRIVER keywords with different +spellings and different case. **A fix shipped on that predicate would have masked nothing while reading +as a fix**, inside the change closing a defect whose whole shape is a control whose domain is narrower +than its surface. `_is_secret_odbc_key` is shape-based and case-insensitive; `pwd` is listed explicitly +because it is an abbreviation matching no substring rule. + +**THE GUARD WRITTEN AGAINST THIS CLASS WAS GREEN OVER IT, AND THAT IS THE REAL FINDING.** +`tests/test_connection_factory_redaction_domain.py` filtered its AST-derived domain through +`_decorator_style`, keeping **4 of 23** spec-returning functions and dropping every base constructor +including `Database`. Its docstring asserted "no shipped factory emits a nested container beyond those +declared below" and called the hole "THEORETICAL rather than live". **Both false.** That claim is +DELETED rather than softened - a number a test has not established has no business in the file defining +the test, and a hedged version keeps the authority while losing the falsifiability. + +The domain is now all 23, and `test_the_domain_covers_every_spec_returning_function` fails if any +discovered function is missing from it. Every other control in that file answers *is this instrument +working* - make it fail on purpose, confirm the injection landed, run a negative control, assert it +examined something. **None of them answers *is it pointed at the whole thing*.** The domain is a +separate claim and now carries its own evidence. + +**Found on the way, and worth more than the fix:** `Http` and `Soap` REFUSE an inline intake +credential outright and demand `env()`, so the value never resolves into settings and no serializer can +leak it. That is the stronger control `odbc_params` lacks, and it is now asserted by +`test_a_refusing_connector_actually_refuses_an_inline_credential` rather than left as folklore. + +**Source:** found 2026-08-09 by the `asvs-tracking-rework` session's independent assessment of ASVS +15.3.1, which I had recused from because I authored the two fixes bearing on that cell. Reproduced here +by execution before any code changed. This is the fourth instance of the class and the second time a +guard written after the previous instance picked a domain narrower than the surface. + +--- + +## 1207. an `env()` ref in a headers table, and a credential in URL userinfo, both escaped redaction + +> ✅ **CLOSED 2026-08-10 — both arms confirmed in the shipped code, not from the report.** `messagefoundry/config/wiring.py`: `_redact_header_value()` opens `if isinstance(value, EnvRef): return {"env": value.key}` — the default dropped for EVERY header, not only credential-shaped ones — and `_mask_url_userinfo()` returns `f"{scheme}//{user}:***@{hostpart}"`, wired into `redacted_settings()` by `elif isinstance(value, str) and name.lower().endswith(_URL_SETTING_SUFFIXES)`, with `_URL_SETTING_SUFFIXES` a NAME set plus suffix rule so bare `proxy_url` is covered. Both reach `display_settings` by delegation. Filed 2026-08-09 - FIXED IN THE SAME CHANGE. Value **7/10** · Difficulty **2/10**. Two holes, both INSIDE surfaces the redactor already claimed to handle. **(b)** the `headers` branch had no `EnvRef` arm, so an `env()` ref in a headers table came back as the RAW object carrying its `default` intact - while the same `env()` on a top-level credential correctly emits `{"env": key}` with the default dropped. **(c)** `url="https://user:SECRET@host"` was returned verbatim by both serializers while `proxy_password` on the SAME object masked. + +**Cluster:** Security / secret disclosure. **Priority:** P1. **Verdict:** build (done). +**Severity:** on a first deployment, both would be served to any `MONITORING_READ` caller and printed +by `graph --json`. (b) discloses a FALLBACK secret - the `env()` default is the value used when the +variable is unset, so it is a credential by construction. No PHI. + +**Measured before and after, both serializers:** + +``` +(b) BEFORE headers={"X-Vendor-Thing": env("acme_key", default=S)} + -> EnvRef(key='acme_key', default='S') raw object, default intact, not JSON-safe + AFTER -> {'env': 'acme_key'} default dropped + control Content-Type: application/json untouched + +(c) BEFORE url=https://user:S@host/y verbatim + proxy_url=http://puser:S@proxy:8080 verbatim + proxy_password on the same object '***' + AFTER url=https://user:***@host/y user, host and path PRESERVED + control https://plain.invalid/path?q=1 untouched +``` + +**WHY THE DEFAULT IS DROPPED FOR EVERY HEADER, not only credential-shaped ones.** The measured +instance used `X-Vendor-Thing`, which matches no substring in the header name rule - so gating the +`EnvRef` arm on that rule would have left this exact case open. A header value sourced from `env()` is +a credential by intent; nobody `env()`-refs a `Content-Type`. The name heuristic is the wrong gate +here, and it is precisely the gate that failed. + +**WHY THE USER, HOST AND PATH SURVIVE.** Only the password half of the userinfo is replaced. An +operator diagnosing a connection needs to see which account and which host; masking the whole URL +would destroy the view rather than protect it, and nothing would report that as a loss. The control +test asserts a URL without userinfo is left byte-identical, because a masker that rewrites every URL +would satisfy the leak assertions while silently mangling ordinary configuration. + +**`proxy` is another parameter-to-setting rename**, noticed while fixing this: the factory parameter +is `proxy` and the emitted setting is `proxy_url`. That is the same boundary `with_signing` crosses +(`private_key` -> `sign_private_key`, BACKLOG #1106) - which is why the URL rule is a NAME set plus a +suffix rule rather than a suffix rule alone. + +**Source:** both found by the `asvs-tracking-rework` session's independent assessment of ASVS 15.3.1, +alongside the `odbc_params` disclosure fixed as #1206. Reproduced here by execution before any code +changed. With these closed, the three surfaces that hold 15.3.1 at `partial` are addressed and the cell +is due a re-read - by that session, not by me, since I authored all three fixes. + +**Process note against myself:** the code comments in this change cited `#1207` BEFORE the number was +allocated. It happened to be next, so nothing collided - but "happened to be next" is exactly the +reasoning `scripts/coord/alloc.ps1` exists to eliminate, and two sessions doing it simultaneously is +the documented failure. Allocate, then write. + +--- + +## 1209. the dependency advisory guard inverts to FAIL-OPEN when the advisory API errors + +> ✅ **CLOSED 2026-08-10 — confirmed in the shipped workflow AND re-executed against a `gh` stub.** `.github/workflows/dependabot-auto-merge.yml` now reads `--jq '[.[] | select(.withdrawn_at == null)] | length' 2>/dev/null)" || count="ERR"` — the `||` binds the ASSIGNMENT, outside the substitution — followed by the shape test `case "$count" in ""|*[!0-9]*)`. Re-run 2026-08-10 under `bash -e` with a stub reproducing the stream split (JSON body to stdout, `gh:` line to stderr, exit 1): the pre-fix form (`|| echo "ERR"` inside + equality sentinel) leaves `count={"message":"API rate limit exceeded",...}ERR`, misses the sentinel, errors "integer expression expected" and emits **advisory_ok=true**; the shipped form leaves `count=ERR` and emits **advisory_ok=false**. Filed 2026-08-09 - FIXED IN THE SAME CHANGE, entry published WITH the fix. Value **9/10** · Difficulty **2/10**. Guardrail #2 of `dependabot-auto-merge.yml` read `count="$(gh api ... || echo "ERR")"`. The `||` runs INSIDE the command substitution, so it APPENDS to stdout rather than replacing it - and `gh api` copies the JSON error BODY to stdout on any HTTP error. The sentinel `[ "$count" = "ERR" ]` therefore misses, and the guard emits `advisory_ok=true` for a lookup that never succeeded. + +**Cluster:** CI / supply chain. **Priority:** P1. **Verdict:** build (done). +**Severity:** unlike the redaction items above, this is not conditional on a first deployment - the +workflow runs in CI today. What bounds it is narrower and worth stating exactly: the engine's merge +condition also requires `age_ok`, and the age step returns false for every ecosystem that can be +`eligible`, a disjointness the file documents about itself. So the falsely-true `advisory_ok` cannot +ALONE merge anything as shipped. It flips a security decision the workflow publishes, and the file +labels the surviving blocker "a FORWARD guard ... load-bearing the day a Python allow row is +populated" - one line's edit away from making this directly merge-affecting. + +**The mechanism, reproduced end to end against the shipped step body:** + +``` +gh api on any HTTP error: JSON body -> STDOUT, "gh: ... (HTTP nnn)" -> stderr (eaten by 2>/dev/null) + count = '{"message":"API rate limit exceeded","status":"403"}ERR' + [ "$count" = "ERR" ] || [ -z "$count" ] -> MISSES (neither) + [ "$count" -lt 1 ] -> "integer expression expected", returns 2 + -> an `if` CONDITION is exempt from `set -e` + -> "::notice::published advisory confirmed", advisory_ok=true, step exits 0 +``` + +Measured by running the real `ghsa` body from `origin/main` and from the fix, under `bash -e`, with a +`gh` stub reproducing the stream split: + +``` + gh ERRORS gh returns 1 +pre-fix (main) advisory_ok=true advisory_ok=true <- FAIL OPEN +fixed advisory_ok=false advisory_ok=true <- fails closed, happy path intact +``` + +**A stub that merely exits non-zero would have proved nothing** - it would pass against the defective +code too. The defect is that the BODY reached the variable, so the stub has to write the body. + +**Where that test actually runs, stated because a skip is not a pass.** The three +`test_the_advisory_guard_fails_closed_when_the_api_errors` rows execute the shipped `run:` body and +therefore need `bash` **and `jq`**. On the maintainer's box Git Bash ships no `jq`, so all three +**SKIP** locally and the file reports `27 passed, 7 skipped` - a green local run that has not exercised +this guard at all. They run on the ubuntu leg and on the two required `windows-2022`/`windows-2025` +legs, whose images carry `jq`. The 2026-08-10 closure therefore did not rest on that local green: the +pre-fix and shipped guards were re-executed by hand under `bash -e` against a body-writing stub, which +needs no `jq`. + +**The comment directly above the defect asserted the opposite:** "Fail closed on any error", and the +header, "a rate-limit/API error or no-matching-advisory routes to manual review, never auto-merge." +A compensating control resting on a false premise, which is the shape SDS-3.7 names. + +**The existing test could not see it.** `test_ghsa_step_queries_the_advisory_api_and_emits_a_guard` +asserted the STRING `"advisory_ok=false" in body` - satisfied by a step that merely CONTAINS the words, +and the fail-open lived underneath a passing version of exactly that check. The file already had the +right instrument: `_run_step_body` executes shipped `run:` bodies under `bash -e` and returns the +parsed `$GITHUB_OUTPUT`. Guardrail #2 was the one guard not using it. +`test_the_advisory_guard_fails_closed_when_the_api_errors` now executes the body across three rows, +including a discriminating PASS so the suite cannot be satisfied by a step that denies unconditionally. + +**The domain, because fixing one instance is how this class survives:** a sweep of 63 workflow and +script files across both repositories found 24 instances of the idiom - 16 provably harmless (`git +rev-parse --verify --quiet` writes nothing on failure), and the rest fixed here. Moving the `||` outside +the substitution also fixes the streaming cases for free: jq emits rows before a mid-array error, and +the old form would have appended the sentinel to a TRUNCATED dependency list while still reporting +success. Assigning on failure discards partial output instead of inheriting it. + +The `count` guard additionally moved from an equality test against one sentinel to a SHAPE test +(`case "$count" in ""|*[!0-9]*)`). An equality test recognises exactly the failure it was told about, +which is how a JSON body walked through it; the numeric comparison's real question is "is this a +number", and only a shape test answers that for values nobody anticipated. + +**Sibling, same idiom, in the private scorecard repo:** its `asvs-verifier-drift.yml` mirror-decision +step fails the opposite way - `remote_tip` holds the 404 body instead of the empty string, so it +refuses to decide on EVERY run where the mirror branch does not exist, which is the steady state. That +one fails closed and is therefore a dead control rather than a disclosure; it is why the daily drift +job has never completed its decision step. + +**Source:** found 2026-08-09 while sweeping for siblings of the drift-workflow defect, after a peer +correctly refuted my first diagnosis of that job's failure (I said the control "detected drift and +could not act"; the scheduled run predated the drift by 88 minutes and its parity step passed - the +control has never yet detected this class at all). + +--- From 391ff8b063f9ff78714b01beff7b01ded531eabf Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 09:55:03 -0500 Subject: [PATCH 08/28] fix(api): BACKLOG #1045 - the PHI property gate now fails CLOSED at the model redact_unauthorized masked a property exactly where it was called, so the set of protected surfaces was only ever the set of call sites someone remembered to write. Coverage was pinned by an enumerated test, which by construction cannot cover the route nobody has written yet: a new PHI-returning route that forgot the call would have serialized summary / error / metadata / last_error / detail in full, with the whole suite green. The default now denies. api/phi_gate.PhiGatedModel withholds each declared PHI property from JSON serialization until an authorization decision is recorded on the instance; redact_unauthorized is what records one, releasing exactly the properties the caller's permissions unlock. A forgotten call returns null - a functional defect its author sees - instead of a leak nobody sees. Mechanics, and why each was chosen over the obvious alternative: - A named field serializer with a `str | None` return, not a model-level wrap serializer. Measured against pydantic 2.13.4 / fastapi 0.141.1: a wrap serializer collapses the model's whole serialization schema to {"type": "object", "additionalProperties": true}, and an Any-returning field serializer untypes its own property. The published OpenAPI must not get vaguer than what the code returns; a test now pins that. - when_used="json", so the gate covers every path by which one of these models reaches a client (FastAPI response models, jsonable_encoder, model_dump_json) while leaving python-mode model_dump alone. api/app.py composes MessageDetail from a MessageSummary dump BEFORE any authorization decision exists; gating that dump would blank the detail route for an authorized caller. - Class creation refuses a gated name outside GATEABLE_PROPERTIES or absent from the model's fields - the one way this gate could go quietly inert. Policy stays in field_authz (which permission unlocks which property); the model declares only that a property is gated. The two are pinned to each other in both directions. Evidence: with phi_gate present but the models unwired, the new end-to-end test went red on exactly the item's failure mode - "a route with no redact_unauthorized call returned PHI: {'summary': 'MRN9001 DOE^JANE', 'error': 'strict validation failed on segment PID', 'metadata': ...}" - and green once the six models declared their gate. Every null assertion ships with a released positive control, so none of them can pass because serialization is simply broken. Synthetic HL7 only. --- docs/SECURITY.md | 19 ++- messagefoundry/api/field_authz.py | 34 +++- messagefoundry/api/models.py | 32 +++- messagefoundry/api/phi_gate.py | 93 +++++++++++ tests/test_field_authz_fail_closed.py | 227 ++++++++++++++++++++++++++ 5 files changed, 389 insertions(+), 16 deletions(-) create mode 100644 messagefoundry/api/phi_gate.py create mode 100644 tests/test_field_authz_fail_closed.py diff --git a/docs/SECURITY.md b/docs/SECURITY.md index ee8f21ca..a4fad12c 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -903,6 +903,15 @@ one place — [`api/field_authz.py`](../messagefoundry/api/field_authz.py) — a `redact_unauthorized()` helper applied to every returned row, rather than re-implemented inline per endpoint (where a new endpoint or field could silently leak PHI — the BOPLA risk, ASVS 8.1.2 / 8.2.3). +**The default for a mapped model denies.** Each of the six response models below is a `PhiGatedModel` +([`api/phi_gate.py`](../messagefoundry/api/phi_gate.py)) that withholds every gated property from JSON +until an authorization decision is recorded on the instance; `redact_unauthorized()` is what records +one, releasing exactly the properties the caller's permissions unlock. A route that never calls it +therefore returns `null` — a functional defect its author sees — rather than the whole model in the +clear. The gate is on JSON serialization, which is every path by which one of these models reaches a +client; a python-mode `model_dump()` stays ungated by design, because the engine composes +`MessageDetail` from a `MessageSummary` dump before any authorization decision exists. + **Read rules — one row per (response object, property).** This table is 1:1 with `PHI_FIELDS`: eleven entries over six response models. Keying on the *object* (not just the property name) is what makes it mechanically comparable to the map — a CI guard asserts set equality in **both** directions, so the @@ -1024,9 +1033,13 @@ this gate can be forgotten (the previous claim here was overstated: the old pinn load-bearing: keyed on names, `last_error` looked covered by `DeadLetterRow.last_error` on `/dead-letters` while `OutboxInfo.last_error` had **zero** coverage, because the only message whose outbox row carries a non-null `last_error` is the dead-lettered one and its detail route was not in - the surface list. It is now, and the coverage assertion is keyed on `(model, property)` pairs. This - is the only guard that catches a future PHI route shipped without its `redact_unauthorized` call — - which, given the fail-open default, no map-level test can. + the surface list. It is now, and the coverage assertion is keyed on `(model, property)` pairs. +- **The default is fail-closed** — `tests/test_field_authz_fail_closed.py` mounts a PHI-returning + route that *omits* the `redact_unauthorized` call and asserts the response carries `null` for every + gated property, each assertion paired with a released positive control. It also pins `PHI_FIELDS` + against each model's own `phi_gated_properties` in both directions, and proves class creation + refuses a gated name the serializer does not cover. The enumeration of call sites above keeps the + *shipped* surfaces honest; this is what makes the route nobody has written yet safe. **Write side (engine → store).** Exception/disposition text is also scrubbed *before* it is stored: a Router/Handler is user code that can `raise ValueError(f"...{raw}")`, so every value written to diff --git a/messagefoundry/api/field_authz.py b/messagefoundry/api/field_authz.py index 8e36c4ef..167001fc 100644 --- a/messagefoundry/api/field_authz.py +++ b/messagefoundry/api/field_authz.py @@ -10,6 +10,11 @@ auditable spot instead of being re-implemented inline per endpoint, where a new endpoint or field could silently leak PHI (the Broken Object Property Level Authorization risk, ASVS 8.2.3). +**The default denies.** The models here are :class:`~messagefoundry.api.phi_gate.PhiGatedModel`\\ s, +which withhold every gated property from JSON until an authorization decision is recorded on the +instance; :func:`redact_unauthorized` is what records one. That module states the mechanism and its +scope — this one owns the policy (which permission unlocks which property). + **Read-side only.** The API exposes no client-writable PHI properties — mutations are coarse, separately permission-gated actions (replay / purge / reload / connection-control) — so there is no per-field *write* authorization surface today. See docs/SECURITY.md "Field-level authorization" for the model and @@ -34,6 +39,7 @@ MessageSummary, OutboxInfo, ) +from messagefoundry.api.phi_gate import PhiGatedModel from messagefoundry.auth import Identity, Permission #: Response model → {property → Permission that unlocks it}. The single source of truth for which @@ -44,7 +50,8 @@ #: PHI-classified column (carrying re-ingress/correlation lineage today, operator/handler-attached #: values by design) — so it must be gated and audited like the other summary-tier PHI fields, not #: returned to a caller lacking view_summary. Add a row here when a new PHI-bearing response property -#: is introduced. +#: is introduced — and name it in the model's own ``phi_gated_properties`` in the same change, which +#: ``tests/test_field_authz_fail_closed.py`` pins in both directions. PHI_FIELDS: dict[type[BaseModel], dict[str, Permission]] = { MessageSummary: { "summary": Permission.MESSAGES_VIEW_SUMMARY, @@ -86,12 +93,25 @@ def gated_properties(model_cls: type[BaseModel]) -> dict[str, Permission]: def redact_unauthorized(model: M, identity: Identity) -> M: # noqa: UP047 - """Return ``model`` with each PHI property the caller may **not** see set to ``None`` — a no-op - when the caller holds every relevant permission. The single per-property read gate (ASVS 8.2.3).""" - withheld = { - prop: None for prop, perm in gated_properties(type(model)).items() if not identity.has(perm) - } - return model.model_copy(update=withheld) if withheld else model + """Return ``model`` with each PHI property the caller may **not** see set to ``None``, and the + rest **released** for serialization. The single per-property read gate (ASVS 8.2.3). + + Release is the half that makes the gate fail-closed (#1045): a :class:`PhiGatedModel` withholds + every gated property from JSON until an authorization decision is recorded on the instance, so + this call is what turns a permitted property back on rather than what turns a forbidden one off. + A route that never calls it returns ``null`` for all of them. + """ + gated = gated_properties(type(model)) + if not gated: + return model + allowed = {prop for prop, perm in gated.items() if identity.has(perm)} + # Still null the values, so `count_exposed` and any server-side read of the returned model agree + # with what is actually serialized. The serializer alone would leave the attribute populated. + withheld: dict[str, None] = {prop: None for prop in gated if prop not in allowed} + out = model.model_copy(update=withheld) + if isinstance(out, PhiGatedModel): + out.release_phi(allowed) + return out def count_exposed(models: Sequence[BaseModel]) -> int: diff --git a/messagefoundry/api/models.py b/messagefoundry/api/models.py index 79703658..1572b096 100644 --- a/messagefoundry/api/models.py +++ b/messagefoundry/api/models.py @@ -6,14 +6,22 @@ separate from the internal SQLite rows and channel-config models so storage/runtime changes don't leak into the API. Message *list* responses carry metadata only; the raw body (PHI) appears only in the single-message detail view, which is audited. + +A model that carries a PHI *property* subclasses :class:`~messagefoundry.api.phi_gate.PhiGatedModel` +and declares it in ``phi_gated_properties``: the property is then withheld from JSON until +:func:`~messagefoundry.api.field_authz.redact_unauthorized` releases what the caller may see, so a +route that forgets that call denies rather than exposes (BACKLOG #1045). Which permission unlocks +which property stays in :mod:`messagefoundry.api.field_authz`; the two are pinned to each other by +``tests/test_field_authz_fail_closed.py``. """ from __future__ import annotations -from typing import Any, Literal +from typing import Any, ClassVar, Literal from pydantic import BaseModel, Field +from messagefoundry.api.phi_gate import PhiGatedModel from messagefoundry.config.ai_policy import ( AiDataScope, AiMode, @@ -31,7 +39,11 @@ class ChannelInfo(BaseModel): destinations: list[str] -class MessageSummary(BaseModel): +class MessageSummary(PhiGatedModel): + # Withheld from JSON until released (BACKLOG #1045). MessageDetail INHERITS this declaration, + # which is deliberate: a future subclass of a PHI-bearing model is gated by default. + phi_gated_properties: ClassVar[frozenset[str]] = frozenset({"summary", "error", "metadata"}) + id: str channel_id: str received_at: float @@ -67,7 +79,9 @@ class MessageSearchResults(BaseModel): scan_limit: int -class OutboxInfo(BaseModel): +class OutboxInfo(PhiGatedModel): + phi_gated_properties: ClassVar[frozenset[str]] = frozenset({"last_error"}) + id: str destination_name: str status: str @@ -76,7 +90,9 @@ class OutboxInfo(BaseModel): last_error: str | None -class EventInfo(BaseModel): +class EventInfo(PhiGatedModel): + phi_gated_properties: ClassVar[frozenset[str]] = frozenset({"detail"}) + ts: float event: str destination: str | None @@ -107,11 +123,13 @@ class MessageDetail(MessageSummary): attachments: list[AttachmentInfo] = Field(default_factory=list) -class CapturedResponseInfo(BaseModel): +class CapturedResponseInfo(PhiGatedModel): """One captured request/response reply (ADR 0013). ``outcome``/``detail`` are visible with the message-read permission; ``body`` is PHI and populated only when the caller also holds the raw-body permission (``None`` otherwise, and ``None`` once retention has purged it).""" + phi_gated_properties: ClassVar[frozenset[str]] = frozenset({"detail"}) + destination_name: str response_seq: int outcome: str @@ -213,9 +231,11 @@ class PurgeResult(BaseModel): cancelled: int -class DeadLetterRow(BaseModel): +class DeadLetterRow(PhiGatedModel): """One dead-lettered delivery (a message→destination that exhausted its retries).""" + phi_gated_properties: ClassVar[frozenset[str]] = frozenset({"summary", "last_error"}) + outbox_id: str message_id: str channel_id: str diff --git a/messagefoundry/api/phi_gate.py b/messagefoundry/api/phi_gate.py new file mode 100644 index 00000000..f8edbf2d --- /dev/null +++ b/messagefoundry/api/phi_gate.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""Fail-closed serialization gate for PHI-bearing response properties (BACKLOG #1045, ASVS 8.2.3). + +:func:`~messagefoundry.api.field_authz.redact_unauthorized` masks a property exactly where it is +called, so the set of protected surfaces was only ever the set of call sites someone remembered to +write. A new PHI-returning route that forgot the call returned every field in full, and no map-level +test could see it — the coverage was pinned by an *enumeration* of routes, which by construction +cannot cover the route nobody has written yet. + +This module inverts the default. A response model that carries PHI subclasses :class:`PhiGatedModel` +and names its PHI properties in ``phi_gated_properties``; those properties then serialize as ``None`` +**until something explicitly releases them**. ``redact_unauthorized`` is that something: it releases +exactly the properties the caller's permissions unlock. A forgotten call therefore yields ``null`` — +a functional defect the author sees immediately — instead of a PHI leak nobody sees at all. + +**Scope, stated honestly.** The gate is on **JSON** serialization (``when_used="json"``): FastAPI +response models, ``jsonable_encoder`` and ``model_dump_json`` all run in JSON mode, which is every +path by which one of these models reaches an HTTP client. A python-mode ``model_dump()`` is +deliberately *not* gated, because the engine composes internally through one — ``api/app.py`` builds +``MessageDetail`` from a ``MessageSummary`` dump *before* any authorization decision exists, so +gating that dump would blank the detail route for an authorized caller. Python-mode dumps stay a +server-side value-passing mechanism; they were never a response. + +The gate is a **default**, not a second authorization decision: it decides *whether an authorization +decision was made*, never *what it should be*. The permission policy stays in one place, +:mod:`messagefoundry.api.field_authz`. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any, ClassVar + +from pydantic import BaseModel, FieldSerializationInfo, PrivateAttr, field_serializer + +__all__ = ["GATEABLE_PROPERTIES", "PhiGatedModel"] + +#: Every property name a :class:`PhiGatedModel` may gate. The base class declares its field +#: serializer over exactly these names (``check_fields=False``, because no single model has them +#: all), so a gated property outside this set would never reach the serializer and would serialize +#: ungated — the one way this gate could go quietly inert. ``__pydantic_init_subclass__`` refuses +#: such a declaration at class-creation time rather than leaving it to review. +GATEABLE_PROPERTIES: frozenset[str] = frozenset( + {"summary", "error", "metadata", "last_error", "detail"} +) + + +class PhiGatedModel(BaseModel): + """A response model whose ``phi_gated_properties`` are withheld from JSON until released.""" + + #: The PHI properties this model gates. Inherited by subclasses on purpose: a future subclass of + #: a PHI-bearing model is gated by default rather than by remembering to re-declare it. + phi_gated_properties: ClassVar[frozenset[str]] = frozenset() + + #: Which gated properties this *instance* is cleared to serialize. Empty is the default, and the + #: default is the control: an instance nobody authorized emits ``null`` for every gated property. + _phi_released: frozenset[str] = PrivateAttr(default=frozenset()) + + @classmethod + def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: + super().__pydantic_init_subclass__(**kwargs) + declared = cls.phi_gated_properties + uncovered = sorted(declared - GATEABLE_PROPERTIES) + if uncovered: + raise TypeError( + f"{cls.__name__}.phi_gated_properties names {uncovered}, which " + f"{__name__}.GATEABLE_PROPERTIES does not cover — the field serializer is declared " + "over that set, so these properties would serialize UNGATED. Add them to " + "GATEABLE_PROPERTIES in the same change." + ) + missing = sorted(declared - set(cls.model_fields)) + if missing: + raise TypeError( + f"{cls.__name__}.phi_gated_properties names {missing}, which are not fields of " + "the model — the declaration gates nothing." + ) + + def release_phi(self, properties: Iterable[str]) -> None: + """Clear ``properties`` (intersected with this model's gate) for JSON serialization.""" + self._phi_released = frozenset(properties) & type(self).phi_gated_properties + + @field_serializer(*sorted(GATEABLE_PROPERTIES), when_used="json", check_fields=False) + def _withhold_unreleased_phi( + self, value: str | None, info: FieldSerializationInfo + ) -> str | None: + """Emit a gated property only once released. The return annotation is load-bearing: it is + what keeps the OpenAPI response schema typed (an ``Any``-returning serializer collapses the + property to an untyped one, and a model-level wrap serializer collapses the whole model).""" + name = info.field_name + if name not in type(self).phi_gated_properties: + return value # covered by the shared decorator, but not gated on this model + return value if name in self._phi_released else None diff --git a/tests/test_field_authz_fail_closed.py b/tests/test_field_authz_fail_closed.py new file mode 100644 index 00000000..90bf0679 --- /dev/null +++ b/tests/test_field_authz_fail_closed.py @@ -0,0 +1,227 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""The PHI property gate is fail-CLOSED at the model, not fail-open at the call site (BACKLOG #1045). + +``redact_unauthorized`` used to be the *only* thing that masked a PHI property, so masking happened +exactly where the call was made and nowhere else. Coverage was pinned by an enumerated test +(``tests/test_field_authz_enforcement_sites.py``), which can only pin the routes someone remembered +to add: a new PHI-returning route that forgot the call would have serialized every gated property in +full, with the whole suite green. + +The default now denies. A PHI-bearing response model withholds each of its declared gated properties +from JSON serialization until something explicitly releases it, and ``redact_unauthorized`` is what +releases the ones the caller's permissions actually unlock. A forgotten call is therefore a route +that returns ``null`` — a functional bug, visible to whoever wrote it — rather than a PHI leak. + +Every assertion below carries its positive control: proving a property comes back ``null`` is +worthless unless the same shape demonstrably returns the value once released. + +Synthetic HL7 only — the MRN and name below are invented. +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any, ClassVar + +import httpx +import pytest + +from messagefoundry.api import create_app +from messagefoundry.api.field_authz import PHI_FIELDS, redact_unauthorized +from messagefoundry.api.models import MessageDetail, MessageSummary +from messagefoundry.api.phi_gate import GATEABLE_PROPERTIES, PhiGatedModel +from messagefoundry.auth import Identity, Permission +from messagefoundry.auth.identity import AuthProvider +from messagefoundry.pipeline import Engine + +#: Invented MRN/name, never real PHI. +_SUMMARY = "MRN9001 DOE^JANE" +_ERROR = "strict validation failed on segment PID" +_METADATA = json.dumps({"user": {"note": "attached by a handler"}}) + + +def _identity(*perms: Permission) -> Identity: + return Identity( + user_id="1", + username="u", + auth_provider=AuthProvider.LOCAL, + roles=frozenset(), + permissions=frozenset(perms), + ) + + +def _summary(**over: Any) -> MessageSummary: + base: dict[str, Any] = dict( # noqa: C408 + id="m1", + channel_id="IB", + received_at=0.0, + source_type="mllp", + control_id="c1", + message_type="ADT^A01", + status="ERROR", + error=_ERROR, + summary=_SUMMARY, + metadata=_METADATA, + ) + base.update(over) + return MessageSummary(**base) + + +def _detail() -> MessageDetail: + return MessageDetail(**_summary().model_dump(), raw="MSH|^~\\&|S|F", outbox=[], events=[]) + + +# --- the default: constructed but not released -> serialized as null ---------------------------- + + +def test_a_freshly_built_phi_model_serializes_its_gated_properties_as_null() -> None: + """The fail-closed default itself. A model nobody released withholds every gated property. + + RULE: this is what a route that forgets ``redact_unauthorized`` now produces. + """ + dumped = _summary().model_dump(mode="json") + withheld = {prop: dumped[prop] for prop in ("summary", "error", "metadata")} + assert withheld == {"summary": None, "error": None, "metadata": None}, ( + f"an unreleased MessageSummary serialized PHI: {withheld}" + ) + # Positive control: the SAME model, released, emits the values — so the assertion above is about + # the gate, not about a seed that never carried PHI or a dump that drops every field. + released = redact_unauthorized(_summary(), _identity(Permission.MESSAGES_VIEW_SUMMARY)) + emitted = released.model_dump(mode="json") + assert emitted["summary"] == _SUMMARY + assert emitted["error"] == _ERROR + assert emitted["metadata"] == _METADATA + + +def test_the_attribute_still_carries_the_value_so_server_side_code_is_unaffected() -> None: + """The gate is on SERIALIZATION, not on the attribute: the engine still composes with the value. + + ``api/app.py`` builds ``MessageDetail`` from ``_summary(row).model_dump()`` before any redaction + decision exists, so a gate that emptied the python-mode dump would silently blank the detail + route for an authorized caller. + """ + row = _summary() + assert row.summary == _SUMMARY + assert row.model_dump()["summary"] == _SUMMARY # python mode: the internal composition path + assert _detail().summary == _SUMMARY + + +def test_redaction_releases_only_what_the_caller_holds() -> None: + """A caller without the permission gets null; a holder gets the value. Both in one place.""" + nonholder = redact_unauthorized(_summary(), _identity(Permission.MESSAGES_READ)) + assert nonholder.model_dump(mode="json")["summary"] is None + holder = redact_unauthorized(_summary(), _identity(Permission.MESSAGES_VIEW_SUMMARY)) + assert holder.model_dump(mode="json")["summary"] == _SUMMARY + + +# --- end to end: a route that forgets the call ------------------------------------------------- + + +@pytest.fixture +async def engine(tmp_path: Path) -> AsyncIterator[Engine]: + eng = await Engine.create(tmp_path / "phi_gate.db", poll_interval=0.05) + try: + yield eng + finally: + await eng.stop() + + +async def test_a_route_that_forgets_redact_unauthorized_denies_rather_than_exposes( + engine: Engine, +) -> None: + """THE item. A PHI-returning route with no ``redact_unauthorized`` call returns nulls. + + The route is mounted on a throwaway app here rather than shipped, because the point is what + happens to a route nobody has written yet. ``allow_no_auth=True`` keeps the test about + serialization: an authenticated caller holding every permission would still get nulls, because + nothing released the model. + """ + app = create_app(engine, allow_no_auth=True) + + @app.get("/test-forgot-the-call", response_model=MessageDetail) + async def forgot() -> MessageDetail: + return _detail() + + @app.get("/test-made-the-call", response_model=MessageDetail) + async def remembered() -> MessageDetail: + return redact_unauthorized(_detail(), _identity(Permission.MESSAGES_VIEW_SUMMARY)) + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://t") as client: + forgotten = (await client.get("/test-forgot-the-call")).json() + assert forgotten["raw"], "the non-PHI half of the model must still be returned" + leaked = {p: forgotten[p] for p in ("summary", "error", "metadata") if forgotten[p]} + assert not leaked, f"a route with no redact_unauthorized call returned PHI: {leaked}" + # Positive control on the SAME app and the SAME model: with the call, the holder sees it. + served = (await client.get("/test-made-the-call")).json() + assert served["summary"] == _SUMMARY + assert served["error"] == _ERROR + assert served["metadata"] == _METADATA + + +# --- the declaration cannot drift --------------------------------------------------------------- + + +def test_every_mapped_model_declares_the_same_gate_on_itself() -> None: + """``PHI_FIELDS`` (which permission unlocks a property) and the model (which properties are + gated) are two statements about one policy; they must agree exactly, in both directions.""" + for model_cls, props in PHI_FIELDS.items(): + assert issubclass(model_cls, PhiGatedModel), ( + f"{model_cls.__name__} is in PHI_FIELDS but is not a PhiGatedModel, so its properties " + "would serialize UNGATED whenever a route forgot to redact it" + ) + assert set(props) == set(model_cls.phi_gated_properties), ( + f"{model_cls.__name__}: PHI_FIELDS gates {sorted(props)} but the model declares " + f"{sorted(model_cls.phi_gated_properties)}" + ) + + +def test_the_gate_does_not_untype_the_published_response_schema() -> None: + """The gate must not be paid for with the API contract. + + A model-level wrap serializer collapses the whole model's serialization schema to + ``{"type": "object", "additionalProperties": true}`` (measured 2026-08-10 against pydantic + 2.13.4 / fastapi 0.141.1), and a field serializer returning ``Any`` untypes its own property — + both would publish a vaguer OpenAPI than the code actually returns. + """ + for model_cls in PHI_FIELDS: + schema = model_cls.model_json_schema(mode="serialization") + assert schema.get("properties"), f"{model_cls.__name__} lost its serialization properties" + for prop in model_cls.phi_gated_properties: + declared = schema["properties"][prop] + assert declared.get("anyOf") == [{"type": "string"}, {"type": "null"}], ( + f"{model_cls.__name__}.{prop} is published as {declared} — the gate untyped it" + ) + + +def test_declaring_a_property_the_serializer_does_not_cover_is_refused() -> None: + """The one way this gate could go quietly inert: a gated property outside the set the base + class's field serializer is declared over would never reach the serializer at all. + + So class creation refuses it. Proven by construction, not by review. + """ + with pytest.raises(TypeError, match="phi_gated_properties"): + + class Ungateable(PhiGatedModel): + phi_gated_properties: ClassVar[frozenset[str]] = frozenset({"not_a_gateable_name"}) + + not_a_gateable_name: str | None = None + + with pytest.raises(TypeError, match="phi_gated_properties"): + + class NotAField(PhiGatedModel): + # A name the serializer covers, but the model has no such field — the declaration is + # a typo that would silently gate nothing. + phi_gated_properties: ClassVar[frozenset[str]] = frozenset({"summary"}) + + # Positive control: the same shape, declared correctly, builds and gates. + class Fine(PhiGatedModel): + phi_gated_properties: ClassVar[frozenset[str]] = frozenset({"summary"}) + + summary: str | None = None + + assert Fine(summary=_SUMMARY).model_dump(mode="json")["summary"] is None + assert "summary" in GATEABLE_PROPERTIES From b9246e2f36de0306909380cecd9711f7c8962ab4 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 09:56:49 -0500 Subject: [PATCH 09/28] docs(archive): re-derive the archive header's item count instead of restating it (BACKLOG #1099) The header read "moved here verbatim on 2026-08-03 so the published backlog is the ~92 items someone can actually act on" -- a PRESENT-TENSE count that was a measurement taken on one day and has drifted every day since. Measured 2026-08-10 the live file holds 241 open items, so the sentence a reader would have quoted was off by a factor of two and a half. Replaced with the derivation rather than a fresher number, because a fresher number rots identically: run parse_items from backlog_status_check.py over BOTH files. That is the same single-source rule the file already states two paragraphs down for reading the status banners, and the rule CLAUDE.md 11 states for the alphabet. Same class as #1099 -- a ledger document asserting something about itself that nothing re-derives. --- docs/archive/backlog/BACKLOG-CLOSED.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/archive/backlog/BACKLOG-CLOSED.md b/docs/archive/backlog/BACKLOG-CLOSED.md index 47afe759..526f14f9 100644 --- a/docs/archive/backlog/BACKLOG-CLOSED.md +++ b/docs/archive/backlog/BACKLOG-CLOSED.md @@ -2,10 +2,18 @@ > ## ⛔ Historical. Nothing here is open work, and nothing here is scheduled. > -> These are the **closed** items from [`docs/BACKLOG.md`](../../BACKLOG.md), moved here verbatim on -> 2026-08-03 so the published backlog is the ~92 items someone can actually act on. Every item keeps -> the status banner it carried at the moment it closed — ✅ shipped · ⛔ declined · 🪦 retired — and -> the banner, not this file's title, remains the authority on what happened to it. +> These are the **closed** items from [`docs/BACKLOG.md`](../../BACKLOG.md), moved here verbatim — +> first on 2026-08-03, and on each archival pass since — so the published backlog is only what someone +> can actually act on. Every item keeps the status banner it carried at the moment it closed — +> ✅ shipped · ⛔ declined · 🪦 retired — and the banner, not this file's title, remains the authority +> on what happened to it. +> +> **Do not quote a count from this paragraph.** It used to read *"the ~92 items someone can actually +> act on"*, a present-tense figure that was a **measurement taken on 2026-08-03** and drifted from the +> day it was written; measured 2026-08-10 the live file held **241** open items. Counts here are +> re-derived, never restated — run `parse_items` from +> [`scripts/docs/backlog_status_check.py`](../../../scripts/docs/backlog_status_check.py) over **both** +> files, which is the same single-source rule that section already states for reading the banners. > > **Moved, not rewritten.** Each block below is byte-identical to the one that left `BACKLOG.md`, > including its heading. That is deliberate and load-bearing: GitHub derives a heading's anchor slug From c526aa25f0141401a6bd4c35c7f29f72cef49fca Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 09:57:43 -0500 Subject: [PATCH 10/28] backlog: drop the clause count from #64's reconciliation (BACKLOG #64) "Two of its six clauses are falsified" / "The other four clauses stand" is a pair of counts that have to agree with each other and with a paragraph nobody will re-parse -- and the second one then named only three things. Replaced with "at least two" and "the remaining clauses", which is CLAUDE.md 11's rule (SDS-3.6: a completeness claim is a liability, prefer "at least" to an enumeration) applied to a sentence I had just written while correcting someone else's stale figures. --- docs/BACKLOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 12e57cd4..0c212acf 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -978,7 +978,7 @@ leading performance driver**. The strategy + the **no-rewrite / no-broker** deci [**ADR 0051**](adr/0051-corepoint-throughput-parity-strategy.md); the engineering note is [`THROUGHPUT-IMPROVEMENTS.md`](archive/throughput/THROUGHPUT-IMPROVEMENTS.md) §5. -**Honest verdict (2026-06-28) — PRE-MEASUREMENT HISTORY. Two of its six clauses are falsified; corrections +**Honest verdict (2026-06-28) — PRE-MEASUREMENT HISTORY. At least two of its clauses are falsified; corrections follow immediately below and are what to read.** Kept dated rather than rewritten, because it is the record the plan was built on. NOT at demonstrated parity at 45M/day (the earlier "at parity" claim was vs Rhapsody *marketing*, not this spec): **compute** unvalidated (only `E_core ≈ 42 msg/s` measured on an @@ -1005,8 +1005,10 @@ encrypt-by-default, **not** inefficiency (the "~2× vs Corepoint" was estimate-v > ([ADR 0055](adr/0055-group-commit-durable-write.md) withdrawal banner). You cannot buy throughput by > consuming less of a resource you are barely touching. Group-commit is not "unbuilt" pending a decision; > it is **WITHDRAWN — DO NOT BUILD**. -> - **The other four clauses stand as written** — the throughput measurement does not bear on carriage, -> HA/multi-DB maturity, or cost/openness, and the "~2× vs Corepoint" retraction was already recorded. +> - **The remaining clauses stand as written**, including the lead "not at demonstrated parity" — the +> throughput measurement does not bear on carriage, HA/multi-DB maturity or cost/openness, and the +> "~2× vs Corepoint" retraction was already recorded. No count is given on purpose: an enumeration here +> would be one more figure to drift. **Ordered plan (each step gated on the one before) — status reconciled 2026-08-10; the step text is the 2026-06-28 original, each verdict is current:** From 5ecdae2ddb5f1e5653b9cbbd7cb82a6021fb4daa Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 10:00:33 -0500 Subject: [PATCH 11/28] feat(parsing): BACKLOG #1049 - $variable-bound XPath, so a Handler has a safe path XmlMessage.find / get / get_all / exists / set / set_attribute took only an expression string into the sole sink self._root.xpath(...), and nothing in the tree bound a value. XmlMessage is exported to code-first Handlers, Handlers are authored by users, and all HL7/XML content is untrusted data - so an author filtering on a message-derived value had no framework-provided alternative to an f-string, and an f-string into an XPath predicate is an injection. Every query method now takes $name bindings as keyword arguments: msg.get("//record[@mrn=$mrn]/note/text()", mrn=untrusted) A bound value is compared as a value and never parsed as expression syntax. The expression (and set's value / set_attribute's name+value) are positional-only, so a binding may legitimately be called "expression" or "value" without colliding with the parameter. Only str/int/float/bool bind: lxml would also accept a node set, which would let a caller feed one expression's result back in as a sub-expression - the shape this API exists to remove. An unbound or unbindable variable surfaces as XmlPathError, the codec's own data error, not a bare lxml traceback out of a transform; the message names the variable and its type, never its value. Evidence, both halves red first: - The threat is demonstrated, not asserted. With mrn = "nope' or @mrn!='", the interpolated form selects BOTH records where the author meant one - test_interpolating_a_tainted_value_into_an_xpath_string_is_injectable passes today and is the live positive control for the empty result below. - The four binding tests failed with "XmlMessage.get_all() got an unexpected keyword argument 'mrn'" before the change and pass after; the same payload bound as $mrn now selects nothing, and the same call shape with a legitimate value still selects its record, so the empty result is the binding working rather than the expression being broken. Invented MRNs throughout, never real PHI. This is a hardening item for the authoring surface, not a shipped vulnerability: nothing in-tree reaches .xpath() with tainted data. --- messagefoundry/parsing/xml/message.py | 62 ++++++++++++++++------- tests/test_xml_message.py | 72 +++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 17 deletions(-) diff --git a/messagefoundry/parsing/xml/message.py b/messagefoundry/parsing/xml/message.py index 133e8fc5..d1d2d0e0 100644 --- a/messagefoundry/parsing/xml/message.py +++ b/messagefoundry/parsing/xml/message.py @@ -12,6 +12,13 @@ XPath is namespace-aware: pass a ``namespaces=`` prefix→URI map and use those prefixes in expressions (lxml requires a prefix for every namespaced element; a default ``xmlns`` still needs a bound prefix). +**Never interpolate message data into an expression.** Every query method takes ``$name`` variable +bindings as keyword arguments — ``msg.get("//record[@mrn=$mrn]/note/text()", mrn=untrusted)`` — and a +bound value is compared as a *value*, never parsed as expression syntax. Handlers are authored by +users and all HL7/XML content is untrusted data, so an f-string into an XPath predicate is an +injection: ``mrn = "nope' or @mrn!='"`` closes the author's quote and turns a one-record lookup into +every record, which for a Handler gating on the match is a filter bypass (BACKLOG #1049, ASVS 1.2.7). + Pure: no I/O to disk/network, no engine imports. """ @@ -24,7 +31,12 @@ from messagefoundry.parsing.xml.errors import XmlError, XmlPathError from messagefoundry.parsing.xml.harden import parse_bytes -__all__ = ["XmlMessage"] +__all__ = ["XPathValue", "XmlMessage"] + +#: What may be bound to an XPath ``$variable``. Scalars only, deliberately: lxml also accepts a node +#: set (an element list, or an ``XPath`` result), which would let a caller feed the result of one +#: expression back in as a sub-expression — the shape this parameterized API exists to remove. +XPathValue = str | int | float | bool class XmlMessage: @@ -32,7 +44,12 @@ class XmlMessage: (``msg.set("//ns:Patient/ns:status", "active")``), and namespace-aware re-encode (``msg.encode()``). Construct via :meth:`parse`. ``namespaces`` (prefix→URI) is bound for every XPath call so - expressions can address namespaced elements.""" + expressions can address namespaced elements. + + Every query method also takes ``$name`` variable bindings as keyword arguments — the safe way to + put message-derived data in a path (see the module docstring). The expression itself is + positional-only, so a binding may be named ``expression``/``value``/``name`` without colliding + with the parameter.""" def __init__(self, root: Any, namespaces: Mapping[str, str] | None = None) -> None: self._root = root @@ -53,22 +70,31 @@ def namespaces(self) -> dict[str, str]: # --- read ---------------------------------------------------------------- - def _xpath(self, expression: str) -> list[Any]: + def _xpath(self, expression: str, variables: Mapping[str, XPathValue]) -> list[Any]: etree = load_lxml() + for name, value in variables.items(): + if not isinstance(value, (str, int, float, bool)): + # PHI-safe: names the variable and its TYPE, never the value. + raise XmlPathError( + f"XPath variable ${name} must be a str/int/float/bool, got " + f"{type(value).__name__}" + ) try: - result = self._root.xpath(expression, namespaces=self._ns or None) + result = self._root.xpath(expression, namespaces=self._ns or None, **variables) except etree.XPathError as exc: - # PHI-safe: names the expression, not the matched content. + # PHI-safe: names the expression, not the matched content. An unbound $variable lands + # here too (lxml raises XPathEvalError), so a Handler's typo is this codec's data error + # rather than a bare lxml traceback out of a transform. raise XmlPathError(f"invalid XPath {expression!r}: {exc}") from exc if isinstance(result, list): return result # A scalar XPath (e.g. count(), string()) — wrap so callers see a uniform list. return [result] - def find(self, expression: str) -> list[Any]: + def find(self, expression: str, /, **variables: XPathValue) -> list[Any]: """Every node/value matching ``expression`` (raw lxml nodes or scalar results). Use :meth:`get`/:meth:`get_all` for text extraction.""" - return self._xpath(expression) + return self._xpath(expression, variables) def _node_text(self, node: Any) -> str: # A scalar XPath result (bool/float/str) or an attribute/text node stringifies directly; an @@ -82,32 +108,32 @@ def _node_text(self, node: Any) -> str: return "".join(node.itertext()) return text if text is not None else "" - def get(self, expression: str) -> str | None: + def get(self, expression: str, /, **variables: XPathValue) -> str | None: """Text of the **first** node matching ``expression``, or None if nothing matches. For an element, its direct text; for ``.../text()`` or ``@attr``, the string value.""" - nodes = self._xpath(expression) + nodes = self._xpath(expression, variables) if not nodes: return None return self._node_text(nodes[0]) - def get_all(self, expression: str) -> list[str]: + def get_all(self, expression: str, /, **variables: XPathValue) -> list[str]: """Text of **every** node matching ``expression`` (empty list if none).""" - return [self._node_text(node) for node in self._xpath(expression)] + return [self._node_text(node) for node in self._xpath(expression, variables)] - def exists(self, expression: str) -> bool: + def exists(self, expression: str, /, **variables: XPathValue) -> bool: """True iff ``expression`` matches at least one node.""" - return bool(self._xpath(expression)) + return bool(self._xpath(expression, variables)) # --- mutate -------------------------------------------------------------- - def set(self, expression: str, value: str) -> None: + def set(self, expression: str, value: str, /, **variables: XPathValue) -> None: """Set the text content of the **single** element matching ``expression`` to ``value``. ``value`` is assigned as text (lxml escapes it on serialize, so it cannot inject markup). Raises :class:`~messagefoundry.parsing.xml.errors.XmlPathError` if the expression matches zero or more than one node, or matches a non-element (e.g. a ``text()``/attribute result — set those via :meth:`set_attribute`).""" - nodes = self._xpath(expression) + nodes = self._xpath(expression, variables) if len(nodes) != 1: raise XmlPathError( f"set requires exactly one matched element for {expression!r}, matched {len(nodes)}" @@ -117,11 +143,13 @@ def set(self, expression: str, value: str) -> None: raise XmlPathError(f"set targets an element; {expression!r} matched a non-element node") node.text = value - def set_attribute(self, expression: str, name: str, value: str) -> None: + def set_attribute( + self, expression: str, name: str, value: str, /, **variables: XPathValue + ) -> None: """Set attribute ``name`` to ``value`` on the **single** element matching ``expression``. Raises :class:`~messagefoundry.parsing.xml.errors.XmlPathError` if it doesn't match exactly one element.""" - nodes = self._xpath(expression) + nodes = self._xpath(expression, variables) if len(nodes) != 1 or not hasattr(nodes[0], "set"): raise XmlPathError( f"set_attribute requires exactly one matched element for {expression!r}" diff --git a/tests/test_xml_message.py b/tests/test_xml_message.py index f2f77a76..874a2979 100644 --- a/tests/test_xml_message.py +++ b/tests/test_xml_message.py @@ -124,6 +124,78 @@ def test_invalid_xpath_raises_path_error() -> None: msg.get("//[broken(") +# --- $variable binding: the safe path for tainted values (BACKLOG #1049) ----- + +#: A record set a Handler would filter on. ``mrn`` is attacker-influenceable — it arrives in the +#: message — so it is exactly the value an author is tempted to paste into an XPath string. +_RECORDS = ( + b'' + b"" + b'routine' + b'restricted' + b"" +) + +#: The injection. Closing the author's quote and appending a second predicate turns a single-record +#: lookup into "every record", which for a Handler gating on the match is a filter bypass. Invented +#: MRNs, never real PHI. +_TAINTED_MRN = "nope' or @mrn!='" + + +def test_interpolating_a_tainted_value_into_an_xpath_string_is_injectable() -> None: + """The threat, demonstrated rather than asserted — the positive control for the safe path below. + + RULE: an absence claim needs a live positive control. Without this, the next test could pass + because the payload was inert rather than because binding neutralised it. + """ + msg = XmlMessage.parse(_RECORDS) + hits = msg.get_all(f"//record[@mrn='{_TAINTED_MRN}']/note/text()") + assert hits == ["routine", "restricted"], ( + "the payload no longer subverts a string-interpolated expression, so this fixture has " + "stopped modelling the injection it exists to model" + ) + + +def test_a_bound_variable_is_matched_as_a_value_not_parsed_as_expression() -> None: + """The framework-provided safe path: the same payload bound as ``$mrn`` matches nothing.""" + msg = XmlMessage.parse(_RECORDS) + assert msg.get_all("//record[@mrn=$mrn]/note/text()", mrn=_TAINTED_MRN) == [] + # Positive control: the SAME call shape with a legitimate value does select its record, so the + # empty result above is the binding working, not the expression being broken. + assert msg.get_all("//record[@mrn=$mrn]/note/text()", mrn="MRN9002") == ["restricted"] + + +def test_variable_binding_is_available_on_every_query_method() -> None: + """A safe path only one accessor offers is a trap for whoever uses the other four.""" + msg = XmlMessage.parse(_RECORDS) + assert msg.get("//record[@mrn=$mrn]/note/text()", mrn="MRN9001") == "routine" + assert msg.exists("//record[@mrn=$mrn]", mrn="MRN9001") is True + assert msg.exists("//record[@mrn=$mrn]", mrn=_TAINTED_MRN) is False + assert len(msg.find("//record[@mrn=$mrn]", mrn="MRN9002")) == 1 + msg.set("//record[@mrn=$mrn]/note", "amended", mrn="MRN9001") + msg.set_attribute("//record[@mrn=$mrn]", "reviewed", "yes", mrn="MRN9002") + again = XmlMessage.parse(msg.encode()) + assert again.get("//record[@mrn='MRN9001']/note/text()") == "amended" + assert again.get("//record[@mrn='MRN9002']/@reviewed") == "yes" + + +def test_binding_works_with_namespaces_and_non_string_values() -> None: + msg = XmlMessage.parse(_DOC, namespaces=_NS) + assert msg.get("//p:name[text()=$who]/text()", who="Smith") == "Smith" + assert msg.get("//p:name[position()=$n]/text()", n=2) == "Smith" + + +def test_an_undeclared_or_unbindable_variable_is_a_path_error() -> None: + """A Handler's mistake must surface as the codec's own data error, never an lxml traceback.""" + msg = XmlMessage.parse(_RECORDS) + with pytest.raises(XmlPathError): + msg.get("//record[@mrn=$mrn]/note/text()") # $mrn never bound + with pytest.raises(XmlPathError): + # Only scalars bind: a node-set- or object-valued variable would let a caller smuggle an + # expression result back into the path. + msg.get("//record[@mrn=$mrn]/note/text()", mrn={"not": "a scalar"}) # type: ignore[arg-type] + + # --- console-carve-out purity: the codec imports no engine package ----------- From 929d69c268751abef570c522ed835a39e20394f8 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 10:00:59 -0500 Subject: [PATCH 12/28] fix(file): claim the archive destination name atomically (BACKLOG #1046) `FileSource._move` relocated a processed file with `path.replace(_unique(dest))` -- a check-then-act pair, where `_unique` asked exists() and `replace` then overwrote whatever sat at the name it chose. The delivery path had already replaced that pattern with `_claim_unique`'s os.link/O_EXCL claim (FILE-5); the archive move was the caller left behind. Route it through the same claim. Claim-then-unlink rather than one rename: the atomic claim cannot be expressed as a rename, because renaming a file over its own hard link is a POSIX no-op and the original would survive. If the unlink fails after the claim, the file is archived AND left to be re-read -- the same duplicate-read outcome the pre-existing failure arm already had, logged the same way. `_claim_unique`'s cross-filesystem fallback now streams instead of read_bytes(): the archive move claims through it too, and an inbound file is only as small as max_file_bytes, which is unset by default -- buffering one whole to claim a name would put an arbitrarily large payload in memory on exactly the filesystems that already cannot hard-link. Scope, stated plainly: the default config cannot race this. One poller per source over an engine-owned processed_dir, and the raw message is durable in the store before the ACK regardless. It bites the non-default config the item names. RED evidence, two threads with a per-round barrier archiving same-named files into one processed_dir: pre-fix, "38 of 120 archived messages were lost or overwritten by the other source" (a standalone probe of the same shape measured 61, 61, 64, 63 and 62 of 120 across five runs). Post-fix: 120 of 120, five runs of five. Real threads rather than an injected interleaving, because the window only exists in the pre-fix code and a hook placed inside it could not survive the fix. Two deterministic controls ship with it -- the escalation still yields `m-1.hl7` without touching the file already there, and the original is still removed, so a `_move` that refused every taken name or that copied instead of moved would not pass. test_file_source_move_failure_leaves_file_in_place is repaired in the same commit, and it is the more interesting half. It patched Path.replace to force a failure and asserted the file was left in place -- but its inbox had no .processed dir, so the move ALSO failed for that reason. Once `_move` stopped calling Path.replace the injection went inert and the test kept passing on the missing directory alone. It now creates the archive dir (so the injection is the only possible cause) and patches the claim. Proved load-bearing: with the patch line removed it reds. --- messagefoundry/transports/file.py | 36 ++++++- tests/test_file_archive_claim.py | 150 ++++++++++++++++++++++++++++++ tests/test_transports.py | 12 ++- 3 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 tests/test_file_archive_claim.py diff --git a/messagefoundry/transports/file.py b/messagefoundry/transports/file.py index 38214f79..77cca870 100644 --- a/messagefoundry/transports/file.py +++ b/messagefoundry/transports/file.py @@ -19,6 +19,7 @@ import logging import os import re +import shutil import tempfile import time from collections import OrderedDict @@ -768,11 +769,36 @@ def _after_processing(self, path: Path) -> None: @staticmethod def _move(path: Path, dest_dir: Path) -> None: + """Archive ``path`` into ``dest_dir`` under a name claimed ATOMICALLY (BACKLOG #1046). + + This used to be ``path.replace(_unique(...))`` — a check-then-act pair, where ``_unique`` + asked ``exists()`` and ``replace`` then overwrote whatever was at the name it chose. Two + pollers sharing one ``processed_dir`` (a non-default config; the default is one poller over + an engine-owned dir) could both be handed the same free name and the second would silently + clobber the first's archived copy. The delivery path had already replaced exactly that + pattern with :func:`_claim_unique`'s ``O_EXCL``/``os.link`` claim (FILE-5); the archive move + was the one caller left on the racy form. + + Claim-then-unlink rather than a single ``replace``: the claim is the whole point, and it + cannot be expressed as a rename (renaming a file over its own hard link is a POSIX no-op, so + the original would survive). If the unlink fails after the claim the file is archived AND + left in place to be re-read — the same duplicate-read outcome the pre-existing failure arm + already had, and logged the same way.""" try: - path.replace(_unique(dest_dir / path.name)) + _claim_unique(path, dest_dir / path.name) except OSError as exc: # A stuck file (locked / dest unwritable) stays and is re-read; log it (FILE-4). logger.warning("could not move %s to %s: %s", path.name, dest_dir.name, exc) + return + try: + path.unlink() + except OSError as exc: + logger.warning( + "archived %s to %s but could not remove the original (it will be re-read): %s", + path.name, + dest_dir.name, + exc, + ) # --- helpers ----------------------------------------------------------------- @@ -863,8 +889,12 @@ def _claim_unique(tmp: Path, target: Path) -> Path: n += 1 candidate = target.with_name(f"{stem}-{n}{suffix}") continue - with os.fdopen(fd, "wb") as handle: - handle.write(tmp.read_bytes()) + # Streamed, not read_bytes(): the archive move claims through here too (#1046), and an + # inbound file is only as small as the operator's max_file_bytes (unset by default), so + # buffering the whole thing to claim a name would put an arbitrarily large inbound payload + # in memory on exactly the filesystems that already can't hard-link. + with open(tmp, "rb") as source, os.fdopen(fd, "wb") as handle: + shutil.copyfileobj(source, handle) return candidate diff --git a/tests/test_file_archive_claim.py b/tests/test_file_archive_claim.py new file mode 100644 index 00000000..802e362c --- /dev/null +++ b/tests/test_file_archive_claim.py @@ -0,0 +1,150 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""The inbound archive move claims its destination name atomically (BACKLOG #1046, ASVS 15.4.4). + +`FileSource._move` used to relocate a processed file with `path.replace(_unique(dest))` — a +check-then-act pair, where `_unique` asked `exists()` and `replace` then overwrote whatever sat at +the name it chose. The delivery path had already replaced that pattern with `_claim_unique`'s +`os.link`/`O_EXCL` claim (FILE-5); the archive move was the caller left behind. + +The default config cannot race it (one poller per source over an engine-owned `processed_dir`, and +the canonical raw message is durable in the store before the ACK regardless), so this is a +concurrency defect with no integrity consequence on the shipping configuration. It bites the +non-default config the item names: two FILE sources sharing one `processed_dir`. +""" + +from __future__ import annotations + +import os +import threading +from pathlib import Path + +import pytest + +from messagefoundry.config.models import ConnectorType, Source +from messagefoundry.transports.file import FileSource, _claim_unique + +#: Enough rounds to make the interleaving reliable rather than lucky. Measured on the pre-fix code +#: (Windows, NTFS, 2026-08-10): five runs of this shape archived 61, 61, 64, 63 and 62 of 120 — +#: roughly half of every archived message lost or refused. Post-fix: 120 of 120, five runs of five. +_ROUNDS = 60 + + +def _source(directory: Path) -> FileSource: + """A FILE source whose `processed_dir` is the SHARED `../processed` beside its watch dir — the + non-default config the item names, where two sources archive into one directory.""" + return FileSource( + Source( + type=ConnectorType.FILE, + settings={"directory": str(directory), "processed_subdir": "../processed"}, + ) + ) + + +def test_two_sources_sharing_one_processed_dir_lose_no_archive(tmp_path: Path) -> None: + """The scenario the item names. Two FILE sources, one shared `processed_dir`, files with the + SAME name archived at the same instant: every archived message must survive under its own name. + + Real threads and a per-round barrier rather than an injected interleaving: the window only + exists in the pre-fix code, so a hook placed inside it could not be carried across the fix. The + barrier makes both archives decide their destination name in the same instant, which is the + whole of the race. + + Mutation: restore `path.replace(_unique(dest_dir / path.name))`. Red: roughly half the archived + files are missing, and the assertion names how many.""" + processed = tmp_path / "processed" + processed.mkdir() + barrier = threading.Barrier(2) + failures: list[BaseException] = [] + + def archive(tag: str) -> None: + inbox = tmp_path / tag + inbox.mkdir() + source = _source(inbox) + try: + for i in range(_ROUNDS): + dropped = inbox / "message.hl7" # deliberately the SAME name in both sources + dropped.write_text(f"{tag}-{i:04d}", encoding="ascii") + barrier.wait(timeout=30) + source._after_processing(dropped) # default after_read="move" + except BaseException as exc: # noqa: BLE001 — re-raised in the main thread below + failures.append(exc) + barrier.abort() + + threads = [threading.Thread(target=archive, args=(tag,)) for tag in ("a", "b")] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + if failures: + raise failures[0] + + archived = sorted(p.read_text(encoding="ascii") for p in processed.iterdir()) + expected = sorted(f"{tag}-{i:04d}" for tag in ("a", "b") for i in range(_ROUNDS)) + assert archived == expected, ( + f"{len(expected) - len(archived)} of {len(expected)} archived messages were lost or " + f"overwritten by the other source" + ) + + +def test_archive_move_removes_the_original(tmp_path: Path) -> None: + """The claim is a MOVE, not a copy. `_claim_unique` links (or copies) and leaves the source + behind, so the unlink that completes the move is a separate step — this is what reds if it is + ever dropped, leaving every processed file to be re-read forever.""" + inbox = tmp_path / "in" + inbox.mkdir() + processed = tmp_path / "processed" + processed.mkdir() + dropped = inbox / "m.hl7" + dropped.write_text("MSH|^~\\&|A|B|C|D|20260101||ADT^A01|1|P|2.5.1\r", encoding="ascii") + + FileSource._move(dropped, processed) + + assert not dropped.exists(), "the archived file must not be left in the watch directory" + assert (processed / "m.hl7").read_text(encoding="ascii").startswith("MSH|"), ( + "the archived copy must carry the original bytes" + ) + + +def test_archive_move_escalates_instead_of_clobbering_a_taken_name(tmp_path: Path) -> None: + """Positive control on the escalation the claim inherits: an already-taken destination name + yields `m-1.hl7`, and the file already sitting there is untouched. + + Without this, a `_move` that refused every archive whose name was taken — or one that simply + overwrote — would still pass the concurrency test above on a lucky scheduling.""" + inbox = tmp_path / "in" + inbox.mkdir() + processed = tmp_path / "processed" + processed.mkdir() + (processed / "m.hl7").write_text("already-archived", encoding="ascii") + dropped = inbox / "m.hl7" + dropped.write_text("newly-processed", encoding="ascii") + + FileSource._move(dropped, processed) + + assert (processed / "m.hl7").read_text(encoding="ascii") == "already-archived" + assert (processed / "m-1.hl7").read_text(encoding="ascii") == "newly-processed" + assert not dropped.exists() + + +def test_claim_unique_copy_fallback_streams_the_bytes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The `O_EXCL` copy fallback (filesystems without hard links: FAT/exFAT, many SMB mounts) must + reproduce the source exactly. It matters more now that the archive move claims through here: a + delivered payload is one message, but an inbound file is only as small as `max_file_bytes`, + which is unset by default. + + Mutation: drop the `copyfileobj` loop. Red: the copied file is empty or truncated.""" + + def _no_hard_links(*_a: object, **_k: object) -> None: + raise OSError("hard links unsupported on this filesystem") + + monkeypatch.setattr(os, "link", _no_hard_links) + payload = bytes(range(256)) * 5000 # 1.28 MB, several read chunks, NUL bytes included + source = tmp_path / "src.bin" + source.write_bytes(payload) + + claimed = _claim_unique(source, tmp_path / "dst.bin") + + assert claimed.read_bytes() == payload diff --git a/tests/test_transports.py b/tests/test_transports.py index 326775fc..8509495e 100644 --- a/tests/test_transports.py +++ b/tests/test_transports.py @@ -567,11 +567,21 @@ async def test_file_source_move_failure_leaves_file_in_place( # catches OSError, logs it, and swallows — the file stays in place (re-read next scan, a bounded # duplicate) rather than crashing the poller or vanishing unrecorded. Monkeypatch (not POSIX chmod) # because this runs on Windows. + from messagefoundry.transports import file as file_mod + inbox = tmp_path / "in" inbox.mkdir() + # The archive dir MUST exist, so the injected failure is the ONLY reason the move can fail. It + # did not before, and a missing destination failed the move on its own — which meant this test + # passed whether or not the injection was still on the code path. It stopped being on it the + # moment _move left Path.replace behind (BACKLOG #1046), and nothing said so. + (inbox / ".processed").mkdir() (inbox / "m.hl7").write_bytes(ADT.encode("utf-8")) src = FileSource(Source(type=ConnectorType.FILE, settings={"directory": str(inbox)})) - monkeypatch.setattr(Path, "replace", _raise_locked) # every move raises + # The atomic destination-name claim is the seam now (#1046): _move claims the name with + # os.link/O_EXCL instead of exists()-then-replace, so a locked/unwritable destination surfaces + # there rather than at Path.replace. + monkeypatch.setattr(file_mod, "_claim_unique", _raise_locked) # every move raises with caplog.at_level(logging.WARNING, logger="messagefoundry.transports.file"): src._after_processing(inbox / "m.hl7") # default after_read="move" assert (inbox / "m.hl7").exists() # left in place, not lost From 1d7c5ae577d4747ca37baa8ee088116e15ea348d Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 10:02:39 -0500 Subject: [PATCH 13/28] fix(worktree): create the branch with --no-track so @{u} answers the asked question (BACKLOG #1087) new.ps1 ran `git worktree add -b ` with -Base defaulting to origin/main. Git branch.autoSetupMerge then set the new branch upstream to the remote-tracking BASE, so @{u} resolved to origin/main rather than to the branch own remote ref -- and @{u}..HEAD reported a fully-pushed branch own commits as UNPUSHED, forever. That number feeds the "is anything at risk if I delete this worktree" check, so a routine safety question got a confidently wrong answer with no error anywhere. The fix is the one flag #1087 names. push.default is NOT touched and must not be: with the upstream wrong, push.default=upstream makes a bare push write the feature branch onto main, and push to main is not blocked server-side here. Measured on a synthetic repo with a real bare origin, both legs, branch tip byte- identical to its pushed remote: worktree add -b feat-a origin/main tip == origin/feat-a ? True @{u} -> exit 0 : origin/main @{u}..HEAD -> exit 0 : 1 <- FALSE unpushed commit bare `git push` -> fatal, and git own remediation text reads "git push origin HEAD:main" worktree add --no-track -b feat-b origin/main tip == origin/feat-b ? True @{u} -> exit 128 : no upstream configured <- loud, not wrong after `git push -u origin feat-b`: @{u} -> origin/feat-b @{u}..HEAD -> 0 Loud beats wrong: an instrument that cannot answer is the correct failure direction. The cost is that the first push is `git push -u origin `, which then sets the upstream to the right value. Documented in new.ps1 and docs/WORKTREES.md. tests/test_worktree_new_no_track.py asserts the DIVERGENCE, per #1000: it extracts the creation command from new.ps1, runs it against a synthetic repo, pushes, and reads @{u}. The control is the pre-fix command written out literally in the same test, so it keeps reproducing the class whichever sanctioned fix new.ps1 later carries. Red first: the control legs passed (upstream origin/main, count 1) and the subject leg failed on `good_up == "origin/main"` -- the defect, in the assertion. A third test scans every .ps1 under scripts/ (38 files, count printed on failure) and refuses `push.default`. It was first written as a raw text scan and fired on new.ps1 own comment WARNING against push.default -- which is how the string match was shown to work; it now skips comment lines, and a real `& git -C $RepoRoot config push.default upstream` appended to new.ps1 was caught at line 262 before being reverted. Two stale premises the fix created, corrected in the same commit rather than left: * new.ps1 lock comment attributed the .git/config.lock race to the upstream write that --no-track removes. The measurement was taken with tracking on and nobody has re-measured without it, so the lock STAYS and the comment now says exactly that. * prune-merged.ps1 signal 3 named new.ps1 as the source of the parent-upstream shape. It is no longer. Note also the improvement: a new.ps1 branch upstream was pinned to the base, which is never origin/, so signal 3 could never fire for one -- after `push -u` it can. --- docs/WORKTREES.md | 34 +++- scripts/worktree/new.ps1 | 38 +++- scripts/worktree/prune-merged.ps1 | 13 +- tests/test_worktree_new_no_track.py | 272 ++++++++++++++++++++++++++++ 4 files changed, 342 insertions(+), 15 deletions(-) create mode 100644 tests/test_worktree_new_no_track.py diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index 720085d3..bc9becf7 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -37,9 +37,33 @@ Then work in it independently: ```powershell cd ..\MessageFoundry-alerts .\.venv\Scripts\Activate.ps1 -# build / commit / push on branch 'alerts'; open a PR as usual +# build / commit; the FIRST push is: +git push -u origin alerts +# thereafter `git push`; open a PR as usual ``` +### The first push needs `-u`, and that is the fix, not a rough edge + +A new branch is created with **`--no-track`**, so it starts with **no upstream** and `git push -u` +is what gives it one — pointing at the branch's **own** remote ref, which is the only value that +answers the question anything keyed on `@{u}` is asking. + +It used to inherit the **base**. `git worktree add -b alerts origin/main` plus git's default +`branch.autoSetupMerge` set `@{u}` to `origin/main`, so **`@{u}..HEAD` reported a branch's own +commits as unpushed forever, including immediately after a successful push**. A session checking +whether its worktrees were safe to remove read **2 and 1 unpushed commits** on two branches that +were byte-identical to their remotes. The loud symptom — `git pull --ff-only` refusing — was the +harmless half; bare `git pull` did **not** fail, it merged `origin/main` in and produced a merge +commit. (BACKLOG #1087.) + +> **Do not "fix" the extra `-u` with `git config push.default upstream`.** It is the first thing +> anyone reaches for and it is **strictly worse than the defect it appears to fix**: with the +> upstream pointing at `origin/main`, a bare `git push` writes the feature branch **onto `main`** — +> and push to `main` is not blocked server-side here. `push.default` being **unset** is what makes +> git use `simple`, which refuses when the upstream's name differs from the branch's. Measured under +> the old configuration, git's own remediation text read `git push origin HEAD:main`. +> `tests/test_worktree_new_no_track.py` scans every `.ps1` under `scripts/` and fails if one sets it. + Point a second Claude Code chat (or VS Code window / EDH) at that directory and the two sessions build in parallel without touching each other's files. @@ -446,9 +470,11 @@ times the 10-minute `-MinIdleMinutes` default — while its process was alive an consults the registry *and* mtime, and **refuses if either says live**, because nothing here can prove a session is gone; only the positive answer is trustworthy. -**Creating a worktree is serialised.** `git worktree add -b ` writes `.git/config`, so two -sessions creating worktrees at once race `.git/config.lock` — on Windows that surfaces as `could not -lock config file .git/config: File exists`, leaving orphaned branches behind. `new.ps1` wraps that call +**Creating a worktree is serialised.** Two sessions running `git worktree add` at once race +`.git/config.lock` — on Windows that surfaces as `could not lock config file .git/config: File +exists`, leaving orphaned branches behind. (That was measured while the add still wrote an upstream, +which is the write `--no-track` now removes; nobody has re-measured the race without it, so the lock +stays until someone does.) `new.ps1` wraps that call in a cross-session mutex ([../scripts/coord/lock.ps1](../scripts/coord/lock.ps1)), which uses the same atomic exclusive-create as `claim.ps1`. It **retries and never steals**: on timeout it fails loudly and names the holder, because breaking a lock you cannot prove is abandoned re-opens the very race it exists diff --git a/scripts/worktree/new.ps1 b/scripts/worktree/new.ps1 index 4dc0411d..6a8a4794 100644 --- a/scripts/worktree/new.ps1 +++ b/scripts/worktree/new.ps1 @@ -15,6 +15,11 @@ worktree never seeds itself from a stale local `main`. Override with -Base; if you point it at a local branch that lags its upstream you get a loud warning. + A NEW branch is created with --no-track, so it has NO upstream and the first push is + `git push -u origin ` (which then sets the upstream to the branch's own remote ref). + Deliberate: inheriting the base as the upstream made `@{u}..HEAD` report a fully-pushed branch's + own commits as unpushed, forever. BACKLOG #1087. + -Name is the DIRECTORY component and cannot contain '/'; -Branch is the git ref and can. -Branch defaults to -Name, which is the ordinary case. @@ -105,13 +110,17 @@ if ($LASTEXITCODE -ne 0) { $branchExists = & git -C $RepoRoot branch --list $Branch Write-Host "Creating worktree '$WorktreePath' on branch '$Branch'..." -# SERIALIZED ACROSS SESSIONS. `git worktree add -b ` writes .git/config (the new -# branch's upstream), and concurrent adds race .git/config.lock. Reported and reproduced on Windows: -# parallel adds against one common .git fail with "could not lock config file .git/config: File -# exists" / "unable to write upstream branch configuration", leaving ORPHANED branches behind and -# callers that never run. Several worktrees already share this .git, so this is a live hazard, not a -# theoretical one. 90s is generous for an operation that takes seconds -- if we wait that long, -# something is genuinely wrong and the throw is the right outcome. +# SERIALIZED ACROSS SESSIONS. Concurrent `git worktree add` against one common .git races +# .git/config.lock. Reported and reproduced on Windows: parallel adds fail with "could not lock config +# file .git/config: File exists" / "unable to write upstream branch configuration", leaving ORPHANED +# branches behind and callers that never run. Several worktrees already share this .git, so this is a +# live hazard, not a theoretical one. 90s is generous for an operation that takes seconds -- if we wait +# that long, something is genuinely wrong and the throw is the right outcome. +# +# THAT MEASUREMENT WAS TAKEN WITH TRACKING ON, and the upstream write its error text names is exactly +# what --no-track below removes (BACKLOG #1087). Nothing has re-measured the race without it, so the +# lock STAYS: holding one that turns out to be unnecessary costs seconds, whereas dropping one on an +# unmeasured inference restores a failure whose signature is orphaned branches. Re-measure first. . "$PSScriptRoot\..\coord\lock.ps1" $addLock = Enter-CoordLock -Name "worktree-add" -TimeoutSeconds 90 -Repo $RepoRoot try { @@ -130,7 +139,20 @@ if ($branchExists) { "-Base $baseUpstream (the default).") } } - & git -C $RepoRoot worktree add $WorktreePath -b $Branch $Base + # --no-track: DO NOT let the new branch inherit the BASE as its upstream (BACKLOG #1087). With + # git's default branch.autoSetupMerge, `-b origin/main` sets branch..merge to + # refs/heads/main, so `@{u}` resolves to origin/main rather than to the branch's own remote ref -- + # and `@{u}..HEAD` then reports a fully-pushed branch's own commits as UNPUSHED, forever. That + # number feeds the "is anything at risk if I delete this worktree" check, so the misconfiguration + # turns a safety question into a confidently wrong answer with no error anywhere. Measured: a + # branch byte-identical to its remote read 1 unpushed commit. + # + # Leaving @{u} UNRESOLVABLE is the point, not a side effect: an instrument that cannot answer + # fails loudly, which is the correct direction. The cost is that the first push needs + # `git push -u origin ` (which then sets the upstream to the branch's OWN remote, the + # right value). Do NOT "fix" that with push.default -- see docs/WORKTREES.md; with the upstream + # wrong, push.default=upstream makes a bare push write this branch onto main. + & git -C $RepoRoot worktree add --no-track $WorktreePath -b $Branch $Base } } finally { Exit-CoordLock $addLock } if ($LASTEXITCODE -ne 0) { throw "git worktree add failed (exit $LASTEXITCODE)" } diff --git a/scripts/worktree/prune-merged.ps1 b/scripts/worktree/prune-merged.ps1 index 92855486..b3e69831 100644 --- a/scripts/worktree/prune-merged.ps1 +++ b/scripts/worktree/prune-merged.ps1 @@ -371,9 +371,16 @@ function Test-Merged { } # 3) Upstream gone: the remote branch was deleted, the usual squash-merge + auto-delete shape. Only - # when the upstream is the branch's OWN remote branch -- `new.ps1 -Base origin/` leaves a - # child branch pointing at the PARENT's upstream, so a merged parent makes a never-pushed child - # report [gone] and its commits would go with the branch. + # when the upstream is the branch's OWN remote branch -- a branch whose upstream points at a + # PARENT (set by hand, or by any tracking `-b origin/`) makes a merged parent + # read [gone] on a never-pushed child, and its commits would go with the branch. + # This used to name new.ps1 as the source of that shape. It no longer is: new.ps1 passes + # --no-track since BACKLOG #1087. Two consequences, and the second is worth knowing -- a fresh + # new.ps1 branch now has NO upstream, so this signal cannot fire for it (it could not before + # either, since origin/main is not origin/); and once it is pushed with `push -u` its + # upstream IS origin/, so this signal starts working for it, which it never did while + # the upstream was pinned to the base. The guard stays regardless: the parent-upstream shape is + # still reachable by hand and refusing it is cheap. # `gone` means THE REMOTE REF IS ABSENT, never `merged`: a branch whose PR was CLOSED, or that was # deleted with `push --delete`, reports exactly this. So it is a signal to remove the WORKTREE, and # never a licence to delete the branch -- which is why the branch delete re-verifies containment diff --git a/tests/test_worktree_new_no_track.py b/tests/test_worktree_new_no_track.py new file mode 100644 index 00000000..b4dfd33c --- /dev/null +++ b/tests/test_worktree_new_no_track.py @@ -0,0 +1,272 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""A new worktree's branch must not inherit the BASE as its upstream (BACKLOG #1087). + +THE DEFECT. ``new.ps1`` created the branch with ``git worktree add -b `` and +``-Base`` defaults to ``origin/main``. Git's ``branch.autoSetupMerge`` then set the new branch's +upstream to the remote-tracking **base**, so ``@{u}`` resolved to ``origin/main`` rather than to the +branch's own remote ref. The visible symptom is trivial; the consequence is not. **``@{u}..HEAD`` +reports a branch's own commits as "unpushed" forever, including immediately after a successful +push** -- and that number is part of how a session decides whether a worktree is safe to remove. A +session read 2 and 1 unpushed commits on two branches that were byte-identical to their remotes. + +WHAT IS ASSERTED, AND WHY IT IS NOT "IS THE UPSTREAM UNSET". Asserting the mechanism passes +trivially and would also pass on a branch nobody ever pushed. The case that distinguishes is a +branch whose tip **equals its pushed remote ref**: on such a branch every instrument keyed on +``@{u}`` must either answer "nothing outstanding" or fail loudly. Answering "1 commit" is the defect. +So the fixture pushes to a real bare origin first, and the assertion is on the reading. + +THE CONTROL IS THE PRE-FIX COMMAND ITSELF, run in the same test against the same fixture. It is +written out literally rather than derived by stripping a flag off the current command, so it keeps +reproducing the class no matter which of the two sanctioned fixes ``new.ps1`` carries later +(``--no-track`` at creation, or setting the upstream to the branch's own remote after first push). + +NOT AN ACCEPTABLE FIX, and there is a guard for it below: ``git config push.default upstream``. With +the upstream pointing at ``origin/main`` that makes a bare ``git push`` write the feature branch onto +``main``, and push to ``main`` is not blocked server-side on this repo. ``push.default`` being UNSET +-- so git uses ``simple``, which refuses when the upstream's name differs from the branch's -- is +load-bearing. +""" + +from __future__ import annotations + +import os +import re +import shlex +import shutil +import subprocess +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parents[1] +_NEW_PS1 = _REPO / "scripts" / "worktree" / "new.ps1" +_SCRIPTS = _REPO / "scripts" + +pytestmark = pytest.mark.skipif( + shutil.which("pwsh") is None or os.name != "nt", + reason="new.ps1 is a PowerShell script and these tests run git the way it does on Windows", +) + +#: The creation call under test: the arm that cuts a NEW branch off the base. The other arm +#: (``worktree add ``, reusing an existing branch) sets no upstream and is not it. +_ADD_NEW_BRANCH = re.compile(r"^\s*&\s*(?Pgit\s+.*\bworktree\s+add\b.*-b\s+\$Branch\b.*?)\s*$") + + +def _code_lines(path: Path) -> list[str]: + """Non-comment lines. The rationale comments quote the command under test, so scanning raw text + would let an explanation satisfy the assertion.""" + return [ + ln for ln in path.read_text(encoding="utf-8").splitlines() if not ln.strip().startswith("#") + ] + + +def _add_command() -> str: + matches = [m.group("cmd") for ln in _code_lines(_NEW_PS1) if (m := _ADD_NEW_BRANCH.match(ln))] + assert len(matches) == 1, ( + f"expected exactly one `worktree add ... -b $Branch` call in {_NEW_PS1.name}, found " + f"{len(matches)}: {matches}. If creation was restructured, re-point this guard rather than " + "letting it pass on an empty set." + ) + return matches[0] + + +def _render(cmd: str, *, repo_root: Path, worktree: Path, branch: str, base: str) -> list[str]: + text = cmd + for token, value in ( + ("$WorktreePath", str(worktree)), + ("$RepoRoot", str(repo_root)), + ("$Branch", branch), + ("$Base", base), + ): + text = text.replace(token, value) + assert "$" not in text, f"unsubstituted variable in the rendered command: {text}" + return [t.strip('"') for t in shlex.split(text, posix=False)] + + +def _git(repo: Path, *args: str) -> str: + proc = subprocess.run( + ["git", "-C", str(repo), *args], check=True, capture_output=True, text=True + ) + return proc.stdout + + +def _git_raw(repo: Path, *args: str) -> tuple[int, str]: + """Exit code plus combined output -- a non-zero exit here is the answer, not a failure.""" + proc = subprocess.run( + ["git", "-C", str(repo), *args], capture_output=True, text=True, check=False + ) + return proc.returncode, (proc.stdout + proc.stderr).strip() + + +class Fixture: + def __init__(self, root: Path) -> None: + self.root = root + self.primary = root / "r" + self.origin = root / "o.git" + + def push_a_commit(self, wt: Path, branch: str) -> None: + """Commit and PUSH -- the state in which the @{u} reading is taken.""" + (wt / f"{branch}.txt").write_text("work", encoding="utf-8") + _git(wt, "add", "--", f"{branch}.txt") + _git(wt, "commit", "-qm", f"work on {branch}") + _git(wt, "push", "-q", "origin", branch) + _git(wt, "fetch", "-q", "origin") + + def is_fully_pushed(self, wt: Path, branch: str) -> bool: + return ( + _git(wt, "rev-parse", "HEAD").strip() + == _git(wt, "rev-parse", f"refs/remotes/origin/{branch}").strip() + ) + + +@pytest.fixture +def fx(tmp_path: Path) -> Fixture: + f = Fixture(tmp_path) + subprocess.run(["git", "init", "-q", "--bare", str(f.origin)], check=True, capture_output=True) + # Windows path budget: a bare repo's incoming-objects temp dir is deep, and these run under a + # pytest tmp path that is already long. + _git(f.origin, "config", "core.longpaths", "true") + f.primary.mkdir(parents=True) + _git(f.primary, "init", "-q", "-b", "main") + _git(f.primary, "config", "user.email", "t@example.invalid") + _git(f.primary, "config", "user.name", "t") + _git(f.primary, "config", "core.longpaths", "true") + # Pin the setting the defect depends on, so the control reproduces regardless of the developer's + # global config. `true` is git's own default: set an upstream when branching off a + # remote-tracking ref, which is exactly the shape new.ps1 uses. + _git(f.primary, "config", "branch.autoSetupMerge", "true") + _git(f.primary, "remote", "add", "origin", str(f.origin)) + (f.primary / "seed.txt").write_text("seed", encoding="utf-8") + _git(f.primary, "add", "--", "seed.txt") + _git(f.primary, "commit", "-qm", "seed") + _git(f.primary, "push", "-q", "origin", "main") + _git(f.primary, "fetch", "-q", "origin") + return f + + +def _upstream_reading(wt: Path, branch: str) -> tuple[int, str, str]: + """What an instrument keyed on ``@{u}`` sees: (exit, upstream-or-error, unpushed-count).""" + up_exit, up = _git_raw(wt, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") + count_exit, count = _git_raw(wt, "rev-list", "--count", "@{u}..HEAD") + return (up_exit or count_exit), up, count + + +def test_the_creation_command_is_extractable_and_carries_a_tracking_decision() -> None: + """Non-vacuity, and a record of which sanctioned fix is in force.""" + cmd = _add_command() + + assert "worktree" in cmd and "add" in cmd + assert "--no-track" in cmd, ( + f"the worktree creation command does not pass --no-track:\n {cmd}\n" + "Without it git's branch.autoSetupMerge sets the new branch's upstream to the BASE " + "(origin/main), and @{u}..HEAD then reports a fully-pushed branch's own commits as unpushed. " + "The other sanctioned fix is setting the upstream to the branch's own remote after the first " + "push; if that is what landed, this assertion is the thing to re-point -- the behavioural " + "test below is the one that must keep passing." + ) + + +def test_a_fully_pushed_worktree_branch_reports_nothing_outstanding(fx: Fixture) -> None: + """The reading, taken on a branch whose tip equals its pushed remote ref. + + The pre-fix command runs first, in the same fixture, and must reproduce the false reading -- + otherwise a green below would be evidence of nothing. + """ + # --- control: the command new.ps1 used to run, written out literally ------------------------ + bad_wt = fx.root / "bad" + subprocess.run( + ["git", "-C", str(fx.primary), "worktree", "add", str(bad_wt), "-b", "bad", "origin/main"], + check=True, + capture_output=True, + text=True, + ) + fx.push_a_commit(bad_wt, "bad") + assert fx.is_fully_pushed(bad_wt, "bad") + bad_exit, bad_up, bad_count = _upstream_reading(bad_wt, "bad") + + assert bad_exit == 0 and bad_up == "origin/main", ( + f"the control did not reproduce the defect (upstream={bad_up!r}, exit={bad_exit}); the " + "assertions below would then prove nothing. Check branch.autoSetupMerge in the fixture." + ) + assert bad_count == "1", ( + f"expected the control to report a FALSE unpushed commit, got {bad_count!r}" + ) + + # --- subject: whatever new.ps1 runs today ---------------------------------------------------- + good_wt = fx.root / "good" + subprocess.run( + _render( + _add_command(), + repo_root=fx.primary, + worktree=good_wt, + branch="good", + base="origin/main", + ), + check=True, + capture_output=True, + text=True, + ) + fx.push_a_commit(good_wt, "good") + assert fx.is_fully_pushed(good_wt, "good") + good_exit, good_up, good_count = _upstream_reading(good_wt, "good") + + # Two end states are acceptable and both are correct failure directions: no upstream at all + # (@{u} unresolvable, which fails LOUDLY), or the branch's OWN remote ref with nothing + # outstanding. Answering "origin/main" and a non-zero count is the defect. + if good_exit == 0: + assert good_up == "origin/good", ( + f"a fully-pushed worktree branch resolves @{{u}} to {good_up!r}. Only the branch's own " + "remote ref answers the question an instrument keyed on @{u} is asking." + ) + assert good_count == "0", ( + f"@{{u}}..HEAD reports {good_count} unpushed commit(s) on a branch whose tip equals " + "origin/good -- the false positive BACKLOG #1087 is about." + ) + else: + assert "no upstream" in good_up.lower() or "no upstream" in good_count.lower(), ( + f"@{{u}} failed for an unexpected reason: {good_up!r} / {good_count!r}" + ) + + # --- and the state ordinary use reaches: `git push -u` --------------------------------------- + # Without an inherited upstream the first push is `push -u`, which sets @{u} to the branch's OWN + # remote ref. That is the end state every later reading is taken in, so it is asserted here + # rather than left to be inferred. Under the control's configuration this is unreachable: with + # @{u} = origin/main a bare push refuses and git's own remediation text reads + # `git push origin HEAD:main`, so nothing ever corrects the upstream. + _git(good_wt, "push", "-u", "-q", "origin", "good") + settled_exit, settled_up, settled_count = _upstream_reading(good_wt, "good") + + assert settled_exit == 0 + assert settled_up == "origin/good" + assert settled_count == "0" + + +def test_no_script_relaxes_push_default(fx: Fixture) -> None: + """``push.default`` unset is what stops a bare push writing a feature branch onto main. + + Scanned: every ``.ps1`` under ``scripts/``. The count is printed in the failure message so a + future reader can tell an empty scan from a clean one. + """ + scanned = sorted(_SCRIPTS.rglob("*.ps1")) + assert len(scanned) > 20, ( + f"only {len(scanned)} PowerShell scripts found under {_SCRIPTS} -- the scan is not reaching " + "the tree it is meant to cover, so a pass here would mean nothing." + ) + # EXECUTABLE lines only. Written first as a raw text scan, which fired on new.ps1's own comment + # WARNING against push.default -- proof the string match works, and proof that a raw scan forbids + # documenting the hazard. A commented-out `git config push.default` sets nothing. + offenders = [ + f"{p.relative_to(_REPO)}:{i}: {ln.strip()}" + for p in scanned + for i, ln in enumerate(p.read_text(encoding="utf-8", errors="replace").splitlines(), 1) + if "push.default" in ln and not ln.strip().startswith("#") + ] + assert not offenders, ( + f"a script configures push.default (scanned {len(scanned)} .ps1 files under scripts/):\n " + + "\n ".join(offenders) + + "\npush.default=upstream is strictly WORSE than the upstream defect it appears to fix: " + "with the upstream pointing at origin/main a bare `git push` writes the feature branch onto " + "main, and push to main is not blocked server-side here. Leave it unset so git uses " + "`simple`, which refuses when the upstream's name differs from the branch's." + ) From fc6ae401982a186c15a58035ccdd5f97485d96c9 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 10:05:59 -0500 Subject: [PATCH 14/28] docs(worktree): say that --no-track does not reach EXISTING worktrees (BACKLOG #1087) The flag only applies at creation. Every worktree made before it still carries @{u} = origin/main and nothing corrects them retroactively, so a reading taken in one of those is still wrong -- and a reader who saw the fix land could reasonably conclude otherwise. Follows the rule that a compensating control must not rest on a false premise: the control here is "the upstream is right now", which is true only of worktrees created since. Both remediations are measured, not inferred, against the same synthetic fixture the item was reproduced in: git push -u origin feat-a @{u} origin/main -> origin/feat-a, count 1 -> 0 git branch --unset-upstream @{u} origin/main -> "fatal: no upstream configured" --- docs/WORKTREES.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index bc9becf7..8a601938 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -64,6 +64,16 @@ commit. (BACKLOG #1087.) > the old configuration, git's own remediation text read `git push origin HEAD:main`. > `tests/test_worktree_new_no_track.py` scans every `.ps1` under `scripts/` and fails if one sets it. +**The flag only reaches worktrees created after it landed.** Every worktree made before it still +carries `@{u} = origin/main`, and nothing retroactively corrects them — so a reading taken in one of +those is still wrong. The same command fixes it, because `-u` overwrites an existing upstream: + +```powershell +git push -u origin # pushed branch: point @{u} at its own remote ref +git branch --unset-upstream # never-pushed branch: leave it unresolvable +git rev-list --count '@{u}..HEAD' # confirm: 0, or a loud "no upstream configured" +``` + Point a second Claude Code chat (or VS Code window / EDH) at that directory and the two sessions build in parallel without touching each other's files. From 23b1f981a56a8b7a4ffced25e5a3afac05514813 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 10:08:38 -0500 Subject: [PATCH 15/28] test: BACKLOG #1043 - the threat-model guard now announces when it is not enforcing Every doc-content assertion in tests/test_threat_model_doc_drift.py went silently inert when docs/security/THREAT-MODEL.md was absent - which on a public checkout it always is, since docs/security/** is deny-listed from the OSS mirror and vaulted. A bare pytest.skip in a 12,000-test run is one 's' among thousands: the run reads clean while ASVS 15.1.3 and 15.1.5, which are scored on that document, have no drift enforcement at all. That is ADR 0158's class 2 - a control that cannot report its own failure - and the fix has to add reporting without adding a false red on the tree where the absence is legitimate. Three changes, and deliberately not a fourth: - The absence is ANNOUNCED, once per run, as a ThreatModelDocUnenforced warning that names the path it looked for, every category of assertion that stopped enforcing, what still enforces, and the two env vars. It lands in pytest's warnings summary, which prints even under -q. The skip reason points at it. - MEFOR_THREAT_MODEL_DOC points the module at a copy elsewhere (the vault working tree), so the content half can be enforced from a checkout that does not carry the file. MEFOR_REQUIRE_THREAT_MODEL_DOC=1 makes absence a hard FAILURE, so a leg that is supposed to enforce fails closed rather than best-effort. - The CHECKER MECHANISM is now verified on every tree, doc or no doc, against a stand-in written in the module. Not a fourth: no public stand-in for the document's CONTENT. Copying the registries into a tracked fixture would be a green proving only that a fixture matches a fixture - one silent skip traded for a vacuous pass, which the item explicitly rules out. The stand-in proves the checkers can go RED, which is the property a skipped run stops evidencing, and nothing more. Evidence - the guard run in all three postures, and the new self-tests broken on purpose: - doc ABSENT: 12 passed, 89 skipped, 1 warning. The warning text is the receipt. - stand-in PRESENT (MEFOR_THREAT_MODEL_DOC at a scratch file): 79 failed, 21 passed, 1 skipped - the content half is live and names what it could not find, e.g. "15.1.5 no longer inventories these dangerous surfaces: ['_exec_module', '_assert_safe_config_source', 'db_lookup', ...]". A guard that reports 89 skips in one posture and 79 named failures in the other is demonstrably not asleep. - MEFOR_REQUIRE_THREAT_MODEL_DOC=1 with the doc absent: "Failed: MEFOR_REQUIRE_THREAT_MODEL_DOC is set, so this run is expected to enforce the threat model's content - but ...THREAT-MODEL.md does not exist." - The row-scoped checker was mutated to scan the whole section instead of its table rows - the regression this module was actually caught by once - and the new self-test went red with "the row-scoped checker did not notice a deleted row - it is asleep". Reverted; green after. The stand-in also caught the author: test_section_slicing_stops_at_the_next_same_or_ higher_heading was written asserting that a ### subsection is EXCLUDED from its ## slice. It is not, by design, and that inclusion is exactly why the anchor checks are scoped to table rows. The assertion now pins the real contract. --- tests/test_threat_model_doc_drift.py | 269 +++++++++++++++++++++++++-- 1 file changed, 249 insertions(+), 20 deletions(-) diff --git a/tests/test_threat_model_doc_drift.py b/tests/test_threat_model_doc_drift.py index bcf80991..552a2dd5 100644 --- a/tests/test_threat_model_doc_drift.py +++ b/tests/test_threat_model_doc_drift.py @@ -34,20 +34,50 @@ today's rows only (15.1.4 exhibited exactly that failure mode when pydicom/signxml landed later). The review checklist carries the "does this change introduce a new dangerous-functionality or resource-demanding surface?" question for that half. + +**Where the document is not present** (BACKLOG #1043). ``docs/security/**`` is deny-listed from the +OSS mirror and vaulted, so on a public checkout the doc-CONTENT assertions have nothing to assert. +They used to answer that with a bare ``pytest.skip``, which in a 12,000-test run is indistinguishable +from a pass — a control that cannot report its own inertness (ADR 0158's class 2). Three changes: + +* the absence is **announced**, once per run, as a :class:`ThreatModelDocUnenforced` warning that + names the path it looked for, what stopped enforcing, and what still does. It lands in pytest's + warnings summary, which prints even under ``-q``; +* ``MEFOR_THREAT_MODEL_DOC`` points the whole module at a copy elsewhere (the vault working tree), + and ``MEFOR_REQUIRE_THREAT_MODEL_DOC`` makes absence a hard **failure** for any leg that is + supposed to have it — so the enforcing posture is fail-closed rather than best-effort; +* the **checker mechanism** is verified on every tree, doc or no doc, against an in-module stand-in + (the ``--- checker self-tests`` section). That is deliberately not a stand-in for the document's + *content*: a public copy of the registries would be a green proving only that a fixture matches a + fixture. What it proves is that these checkers can go red — including the one trap this module has + actually been caught by, a row deleted while the section's prose still carries its anchor. """ from __future__ import annotations import os import re +import warnings from pathlib import Path import pytest _ROOT = Path(__file__).resolve().parent.parent -_DOC = _ROOT / "docs" / "security" / "THREAT-MODEL.md" _PKG = _ROOT / "messagefoundry" +#: Point this at a copy of the document (e.g. the vault working tree) to enforce the content half +#: from a checkout that does not carry it. +_DOC_ENV = "MEFOR_THREAT_MODEL_DOC" +#: Set truthy where the document is EXPECTED to be present. Absence is then a failure, not a notice. +_REQUIRE_ENV = "MEFOR_REQUIRE_THREAT_MODEL_DOC" + + +def _doc_path() -> Path: + """Where the threat model is read from — the env override, else the in-tree (vault) location.""" + override = os.environ.get(_DOC_ENV, "").strip() + return Path(override) if override else _ROOT / "docs" / "security" / "THREAT-MODEL.md" + + _RESOURCE_HEADING = "## Resource-demanding functionality (ASVS 15.1.3)" _DANGEROUS_HEADING = "## Dangerous functionality (ASVS 15.1.5)" _BOUNDARY4_HEADING = "### Web console (browser `/ui`) — boundary 4" @@ -240,27 +270,74 @@ # --- helpers ------------------------------------------------------------------------------------ +class ThreatModelDocUnenforced(UserWarning): + """The threat model is absent from this checkout, so the doc-CONTENT assertions are inert. + + A warning rather than a skip reason because a skip is invisible: it prints as one ``s`` among + thousands, and the run still reads as clean. This lands in pytest's warnings summary, which is + printed even under ``-q``. + """ + + +#: Announce once per session, not once per inert test — 80-odd identical entries in the warnings +#: summary would be its own kind of unreadable. +_ANNOUNCED = False + + +def _announce_absence(path: Path) -> None: + global _ANNOUNCED + if _ANNOUNCED: + return + _ANNOUNCED = True + warnings.warn( + ThreatModelDocUnenforced( + f"{path} is absent from this checkout (docs/security/** is deny-listed from the OSS " + "mirror and vaulted), so EVERY doc-content assertion in " + "tests/test_threat_model_doc_drift.py is INERT in this run: the heading/table structure, " + "the 15.1.3 and 15.1.5 anchor registries, the planted-omission self-tests over the real " + "document, the setting-name resolution, the doc side of the numeric parity loop, and the " + "absence-claim tripwire. ASVS 15.1.3 and 15.1.5 are scored on that document, so on this " + "tree they have no drift enforcement at all. Still enforced here: the checker self-tests, " + "the subprocess-site inventory, the shell-execution-site invariant, the registered-source " + "coverage decision, and every live-constant lock. Set " + f"{_DOC_ENV}= to enforce the content half from this checkout, or " + f"{_REQUIRE_ENV}=1 to make its absence a hard failure." + ), + stacklevel=3, + ) + + def _doc_text() -> str: - """The threat-model text, or a per-test skip where the document is not published. + """The threat-model text, or an ANNOUNCED per-test skip where the document is not published. ``docs/security/**`` is deny-listed from the OSS mirror (and vaulted after the cutover), so this - document is absent there and every assertion about its CONTENT has nothing to say. The skip is - deliberately here, at the single accessor, rather than at module level — that distinction is the - whole point: - - * A module-level skip would take the FIVE code-only tests down with the 88 doc-anchored ones, - including the only inventory of process-spawn sites and the only exact-value lock on ~60 shipped - security defaults. Those assert about CODE and are just as valid on the public repo. - * Skipping at the accessor also keeps the code half of a MIXED test enforced. ``test_subprocess_ - sites_are_exactly_the_documented_set`` asserts ``live == known`` over the package and only THEN - reads the doc: if that assertion fails you never reach the skip, so the test still fails. Before - this, that test failed outright on the mirror and the process-spawn inventory guarded nothing - there — a list-form ``subprocess.Popen`` could ship unnoticed, which is the exact defect its - docstring records having already happened once. + document is absent there and every assertion about its CONTENT has nothing to say. Two decisions: + + * **The skip is here, at the single accessor, not at module level.** A module-level skip would + take the code-only tests down with the doc-anchored ones, including the only inventory of + process-spawn sites and the only exact-value lock on the shipped security defaults. Those + assert about CODE and are just as valid on the public repo. It also keeps the code half of a + MIXED test enforced: ``test_subprocess_sites_are_exactly_the_documented_set`` asserts + ``live == known`` over the package and only THEN reads the doc, so if that assertion fails you + never reach the skip. Before that, it failed outright on the mirror and the process-spawn + inventory guarded nothing there — a list-form ``subprocess.Popen`` could ship unnoticed, which + is the exact defect its docstring records having already happened once. + * **The skip announces itself** (#1043). Silence here was a green that meant nothing. """ - if not _DOC.exists(): - pytest.skip("docs/security/THREAT-MODEL.md is private-only (OSS-mirror deny-list / vault)") - return _DOC.read_text(encoding="utf-8") + path = _doc_path() + if not path.exists(): + if os.environ.get(_REQUIRE_ENV, "").strip().lower() in {"1", "true", "yes", "on"}: + pytest.fail( + f"{_REQUIRE_ENV} is set, so this run is expected to enforce the threat model's " + f"content — but {path} does not exist. Point {_DOC_ENV} at the document, or unset " + f"{_REQUIRE_ENV} to run in the announced, non-enforcing posture." + ) + _announce_absence(path) + pytest.skip( + f"{path} is absent — the doc-content half is NOT enforced in this run; see the " + "ThreatModelDocUnenforced entry in the warnings summary for what that costs" + ) + return path.read_text(encoding="utf-8") def _section(text: str, heading: str) -> str: @@ -834,7 +911,7 @@ def test_relative_links_in_the_scored_sections_resolve(heading: str) -> None: missing = [ target for target in _MD_LINK_RE.findall(section) - if not (_DOC.parent / target).resolve().exists() + if not (_doc_path().parent / target).resolve().exists() and not (_ROOT / "docs" / target).resolve().exists() ] assert not missing, ( @@ -850,7 +927,7 @@ def test_the_open_gap_is_tracked_against_an_artifact_that_exists() -> None: targets = _MD_LINK_RE.findall(note) assert targets, "the honest-gap note cites no tracking artifact" for target in targets: - path = (_DOC.parent / target).resolve() + path = (_doc_path().parent / target).resolve() assert path.exists(), f"the cited tracker {target} does not exist" assert "15.2.2" in path.read_text(encoding="utf-8"), ( f"{target} does not mention 15.2.2, so it does not track the gap this note assigns to it" @@ -1091,3 +1168,155 @@ def test_every_dangerous_row_is_pinned_by_an_anchor_or_a_row_key() -> None: "15.1.5 row(s) with no _DANGEROUS_ANCHORS token and no _DANGEROUS_ROW_KEYS entry — deleting " f"them would not red CI: {unpinned}" ) + + +# --- checker self-tests: the half that must run on EVERY tree (BACKLOG #1043) -------------------- +# +# Everything above this line asserts about a document that is absent from a public checkout. These +# assert about the CHECKERS, against a stand-in written here, and therefore never go inert. They are +# not a stand-in for the document's content — a public copy of the registries would be a green +# proving only that a fixture matches a fixture. They prove the checkers can go RED, which is the +# property a skipped run silently stops evidencing. + +#: A miniature of the two scored sections. The prose after each table repeats a row's anchor on +#: purpose: that is the exact shape that once left `_missing` green next to a deleted row, and it is +#: why `_missing_in_rows` exists. +_STAND_IN = """\ +# Stand-in threat model + +## Resource-demanding functionality (ASVS 15.1.3) + +| Surface | Attacker-influenceable input | Bound in force | +|---|---|---| +| **Widget parse** | the widget body | capped by `widget_limit` at 4 MiB | +| **Gadget poll** | the polled row set | capped by `gadget_limit` | + +Closing note: `widget_limit` is also named here, in prose, after the table. See +[CONFIGURATION.md](CONFIGURATION.md) and the `[api].serve_ui` setting. + +## Dangerous functionality (ASVS 15.1.5) + +| Functionality | Guard | +|---|---| +| **Shell hook** | runs via `create_subprocess_shell` behind an operator-only setting | + +### First subsection + +subprocess.Popen appears here, in prose inside the 15.1.5 section. + +### Second subsection + +nothing of interest. +""" + + +def _stand_in_resource_section() -> str: + return _section(_STAND_IN, _RESOURCE_HEADING) + + +def test_section_slicing_stops_at_the_next_same_or_higher_heading() -> None: + """The contract is *same-or-higher* level, so a `###` subsection stays inside its `##` parent. + + Worth pinning rather than assuming: that inclusion is precisely why the anchor checks are scoped + to TABLE ROWS. A `##` slice carries its subsections' prose, so a section-scoped token search can + be satisfied by text that is not a row. (This assertion was written the other way round first, + and the stand-in caught it — which is the point of having one.) + """ + resource = _stand_in_resource_section() + assert "**Widget parse**" in resource + assert "**Shell hook**" not in resource, "the 15.1.3 slice swallowed the next `##` section" + dangerous = _section(_STAND_IN, _DANGEROUS_HEADING) + assert "**Shell hook**" in dangerous + assert "First subsection" in dangerous, "a `###` subsection is part of its `##` section" + sub = _section(_STAND_IN, "### First subsection") + assert "subprocess.Popen" in sub + assert "Second subsection" not in sub, "a `###` slice swallowed the next `###`" + + +def test_table_row_extraction_drops_the_header_and_separator() -> None: + rows = _table_rows(_stand_in_resource_section()) + assert len(rows) == 2, f"expected the two body rows, got {rows}" + assert _row_first_cells(_stand_in_resource_section()) == ["**Widget parse**", "**Gadget poll**"] + + +def test_a_deleted_row_is_detected_even_though_the_prose_still_carries_its_anchor() -> None: + """THE trap this module has already been caught by, proven detectable on every tree. + + RULE: a gate that has never been red is a claim. Here the red is produced on purpose: drop the + `widget_limit` ROW while the closing prose keeps naming `widget_limit`, and the row-scoped + checker must still report it — while the section-scoped one demonstrably does not. + """ + section = _stand_in_resource_section() + assert _missing_in_rows(section, ("widget_limit", "gadget_limit")) == [] + + mutated = _drop_one_row_containing(section, "widget_limit") + assert _missing_in_rows(mutated, ("widget_limit",)) == ["widget_limit"], ( + "the row-scoped checker did not notice a deleted row — it is asleep" + ) + # Only ONE row goes: the sibling must still be found, or the mutation is too blunt to model a + # realistic single-row regression. + assert _missing_in_rows(mutated, ("gadget_limit",)) == [] + # And the section-scoped checker is blind to it, because the prose still says `widget_limit`. + # That is not a defect here; it is the measured reason the row-scoped one exists. + assert _missing(mutated, ("widget_limit",)) == [] + + +def test_the_row_mutation_helper_refuses_to_silently_do_nothing() -> None: + """If the needle is gone, `_drop_one_row_containing` must fail rather than return the text + unchanged — otherwise every planted-omission self-test above would pass vacuously.""" + with pytest.raises(AssertionError, match="no table row containing"): + _drop_one_row_containing(_stand_in_resource_section(), "a-token-in-no-row") + + +def test_the_token_and_link_scanners_find_what_they_claim_to() -> None: + assert ("api", "serve_ui") in _TOML_TOKEN_RE.findall(_STAND_IN) + assert _MD_LINK_RE.findall(_STAND_IN) == ["CONFIGURATION.md"] + assert _SUBPROCESS_RE.search("create_subprocess_shell(...)") is not None + assert _SUBPROCESS_RE.search("subprocess.Popen([...])") is not None + assert _SUBPROCESS_RE.search("a line that spawns nothing") is None + + +# --- the enforcement posture is itself asserted -------------------------------------------------- + + +def test_the_doc_content_half_reports_whether_it_is_enforcing() -> None: + """Neither posture may be silent (#1043). + + Present: the accessor returns the document, so the assertions above are live. Absent: the + accessor emits a ThreatModelDocUnenforced warning naming what stopped enforcing, and only THEN + skips. A run that quietly skipped 80-odd assertions and reported green is the defect being fixed, + so the announcement is asserted rather than assumed. + """ + global _ANNOUNCED + path = _doc_path() + if path.exists(): + assert _doc_text().strip(), f"{path} exists but is empty — the content half asserts nothing" + return + was_announced, _ANNOUNCED = _ANNOUNCED, False + try: + with ( + pytest.warns(ThreatModelDocUnenforced, match=str(_DOC_ENV)), + pytest.raises(pytest.skip.Exception), + ): + _doc_text() + finally: + _ANNOUNCED = was_announced + + +def test_requiring_the_document_turns_its_absence_into_a_failure( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The fail-closed arm, for a leg that IS supposed to carry the document. + + Both directions are exercised, so neither the env name nor the truthiness test can rot: pointed + at a real file the accessor reads it, pointed at a missing one under the require flag it fails. + """ + monkeypatch.setenv(_REQUIRE_ENV, "1") + monkeypatch.setenv(_DOC_ENV, str(tmp_path / "absent" / "THREAT-MODEL.md")) + with pytest.raises(pytest.fail.Exception, match=_REQUIRE_ENV): + _doc_text() + + stand_in = tmp_path / "THREAT-MODEL.md" + stand_in.write_text(_STAND_IN, encoding="utf-8") + monkeypatch.setenv(_DOC_ENV, str(stand_in)) + assert _doc_text() == _STAND_IN, "the override is not what the accessor actually reads" From b6a45c301224fa84d3ab9ffde8a891d484f95f13 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 10:09:55 -0500 Subject: [PATCH 16/28] feat(api): bound how long a handler may build a response (BACKLOG #1044) ASVS 15.1.3's "avoid building a response that takes longer than the consumer's timeout" limb (properly 15.2.2) had no server-side enforcement. The only asyncio.wait_for in api/ caps the connection-test probe; nothing bounded a handler, so a slow one held its worker for as long as it ran and the client's own timeout was the only thing that ever gave up -- which does not free the server. No exposure on the shipping config (loopback, authenticated, single worker); this is what a first deployment would need. RequestTimeoutMiddleware, pure ASGI, refuses with 503 when the handler has not begun responding within the deadline. The bound is on BUILDING the response, not on sending it: the clock is cancelled at http.response.start, so an attachment download or a large log body already streaming is never cut mid-body over a slow link. That is also the limb ASVS words -- the cost being bounded is the handler's, not the network's. Registered directly inside ClientNetworkMiddleware and outside everything else, so the deadline covers the attachment CSP re-assert, the console's own middleware, the body cap, the security-headers middleware and every auth dependency, while a refused address is still rejected before it can occupy a deadline. That order is pinned by a test, not just described here. Sitting outside _security_headers, the 503 sets the baseline headers itself rather than being the one response in the API with none of them. Default 120s: a runaway backstop, not a latency budget. Overridable per app via app.state.request_timeout_seconds (<=0 disables) -- the seam a future [api] knob would set, so a deployment with a genuinely long admin operation raises it instead of losing the backstop everywhere. Not wired to config in this change. Watched RED against the unregistered code: the slow route returned 200 with {"status": "finished"} after running to completion. Positive controls ship with it -- a fast handler under the same deadline still returns its own 200, a disabled deadline lets the slow handler finish, and a non-numeric state value falls back to the default rather than disabling the control. --- messagefoundry/api/app.py | 15 +++ messagefoundry/api/request_timeout.py | 118 ++++++++++++++++++ tests/test_api_request_timeout.py | 171 ++++++++++++++++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 messagefoundry/api/request_timeout.py create mode 100644 tests/test_api_request_timeout.py diff --git a/messagefoundry/api/app.py b/messagefoundry/api/app.py index 92968ff0..216acf14 100644 --- a/messagefoundry/api/app.py +++ b/messagefoundry/api/app.py @@ -161,6 +161,7 @@ MultipartTooLargeError, parse_single_file_upload, ) +from messagefoundry.api.request_timeout import RequestTimeoutMiddleware from messagefoundry.api.security import ( authorize_ws, client_ip, @@ -5097,6 +5098,20 @@ def _oidc_authorization_host(endpoint: str) -> str: # OUTSIDE the serve_ui guard so the JSON-only deployment is covered identically. app.add_middleware(AttachmentSecurityHeadersMiddleware) + # ASVS 15.1.3/15.2.2 (BACKLOG #1044) — the server-side deadline on BUILDING a response. Nothing + # bounded a handler before this: the only asyncio.wait_for in api/ caps the connection-test + # probe, so a slow handler held its worker for as long as it ran and the client's own timeout + # was the only thing that ever gave up (which does not free the server). + # + # Registered here so it lands OUTSIDE every earlier registration — the attachment CSP re-assert, + # the console's UiSecurityHeadersMiddleware, the body cap, the security-headers middleware and + # every auth dependency are all inside the deadline, which is what makes it a bound on the whole + # request rather than on the route function alone. It stays INSIDE ClientNetworkMiddleware + # (registered after it, so further out): a refused address must be rejected before it can occupy + # a deadline at all. The clock is cancelled at http.response.start, so a response that has begun + # streaming is never cut mid-body — see api/request_timeout.py. + app.add_middleware(RequestTimeoutMiddleware) + # [security].allowed_client_networks — registered LAST, so Starlette makes it the OUTERMOST user # middleware (add_middleware inserts at index 0; the stack is built from reversed(user_middleware)). # It therefore runs above the attachment CSP re-assert, the console's UiSecurityHeadersMiddleware, diff --git a/messagefoundry/api/request_timeout.py b/messagefoundry/api/request_timeout.py new file mode 100644 index 00000000..29b985f4 --- /dev/null +++ b/messagefoundry/api/request_timeout.py @@ -0,0 +1,118 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""Server-side request deadline for the engine API (BACKLOG #1044, ASVS 15.1.3 / 15.2.2). + +ASVS 15.1.3's "avoid building a response that takes longer than the consumer's timeout" limb had no +server-side enforcement: the only ``asyncio.wait_for`` in ``api/`` bounded the connection-test probe, +and nothing bounded a handler. A slow handler held its worker for as long as it ran, with the client's +own timeout the only thing that ever gave up — and a client giving up does not free the server. + +**The bound is on BUILDING the response, not on sending it.** The deadline is cancelled the instant +``http.response.start`` goes out, so a response that has begun streaming — an attachment download, a +large log body — is never cut mid-body over a slow link. That is also the limb ASVS words: the cost +being bounded is the handler's, not the network's. + +**Pure ASGI, not** ``BaseHTTPMiddleware``: no task hop, and the response-start signal is visible +directly. It is registered inside :class:`~messagefoundry.api.client_networks.ClientNetworkMiddleware` +(a refused address is rejected before it can occupy a deadline) and outside everything else, so the +deadline covers auth dependencies, the body cap and the route alike. + +Being outside the app's ``_security_headers`` middleware, the refusal sets the baseline response +headers itself rather than shipping a 503 with none of them — the same reason +``ClientNetworkMiddleware`` carries its own ``_DENIAL_HEADERS``. +""" + +from __future__ import annotations + +import asyncio +import logging + +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +__all__ = ["RequestTimeoutMiddleware", "DEFAULT_REQUEST_TIMEOUT_SECONDS", "TIMEOUT_STATE_ATTR"] + +_log = logging.getLogger(__name__) + +#: Wall-clock ceiling on building one HTTP response, in seconds. Deliberately generous: this is a +#: runaway-handler backstop, not a latency budget, and every route that has its own expectation +#: already carries a tighter one (the connection-test probe caps itself at 35s). A deployment with a +#: genuinely long admin operation — an integrity check over a very large store — raises it through +#: the state attribute below rather than losing the backstop everywhere else. +DEFAULT_REQUEST_TIMEOUT_SECONDS = 120.0 + +#: ``app.state`` attribute that overrides the default; ``<= 0`` disables the deadline entirely. +#: Read per request with a default, the same posture ``ClientNetworkMiddleware`` uses for its own +#: state, so a bare ASGI scope with no app degrades to the shipped default rather than crashing. +#: This is the seam a ``[api]`` knob would set; it is not wired to config today. +TIMEOUT_STATE_ATTR = "request_timeout_seconds" + +#: Set directly because this middleware sits OUTSIDE the app's ``_security_headers``, so a refusal +#: short-circuits it. Mirrors ``client_networks._DENIAL_HEADERS`` minus its denial marker. +_TIMEOUT_HEADERS: tuple[tuple[bytes, bytes], ...] = ( + (b"content-type", b"application/json"), + (b"x-content-type-options", b"nosniff"), + (b"referrer-policy", b"no-referrer"), + (b"x-frame-options", b"DENY"), + (b"cache-control", b"no-store"), +) + +#: PHI-free, and it names no path, no parameter and no internal detail — a timeout is exactly the +#: signal an attacker probes for expensive routes with. +_TIMEOUT_BODY = b'{"detail":"the server timed out building a response"}' + + +class RequestTimeoutMiddleware: + """Refuse with 503 when a handler has not begun responding within the deadline.""" + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + # Lifespan and websockets pass through untouched: a websocket is long-lived BY DESIGN, and + # swallowing lifespan would break startup/shutdown outright. + if scope["type"] != "http": + await self.app(scope, receive, send) + return + state = getattr(scope.get("app"), "state", None) + seconds = getattr(state, TIMEOUT_STATE_ATTR, DEFAULT_REQUEST_TIMEOUT_SECONDS) + if not isinstance(seconds, int | float) or seconds <= 0: + await self.app(scope, receive, send) + return + + started = False + + try: + async with asyncio.timeout(seconds) as deadline: + + async def send_wrapper(message: Message) -> None: + nonlocal started + if message["type"] == "http.response.start": + started = True + # The handler produced a response; from here the clock belongs to the + # network, not to us. Cancelling the deadline is what keeps a slow + # attachment download from being cut mid-body. + deadline.reschedule(None) + await send(message) + + await self.app(scope, receive, send_wrapper) + except TimeoutError: + if started: + # Unreachable while the reschedule above stands, and deliberately not swallowed: + # the response is already on the wire, so there is nothing to replace it with. + raise + # Logged, not silent — a control that refuses work must be visible or an operator + # cannot tell a deadline from an outage. The path is engine-routed, never a body. + _log.warning( + "request timed out after %.1fs building a response for %s %s", + seconds, + scope.get("method", "?"), + scope.get("path", "?"), + ) + await send( + { + "type": "http.response.start", + "status": 503, + "headers": list(_TIMEOUT_HEADERS), + } + ) + await send({"type": "http.response.body", "body": _TIMEOUT_BODY}) diff --git a/tests/test_api_request_timeout.py b/tests/test_api_request_timeout.py new file mode 100644 index 00000000..ecfa6cb6 --- /dev/null +++ b/tests/test_api_request_timeout.py @@ -0,0 +1,171 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""The engine API bounds how long a handler may build a response (BACKLOG #1044). + +ASVS 15.1.3's "avoid building a response that takes longer than the consumer's timeout" limb (properly +15.2.2 territory) had no server-side enforcement: the only `asyncio.wait_for` in `api/` bounded the +connection-test probe. A slow handler held its worker for as long as it ran. + +There is no exposure on the shipping config -- loopback bind, authentication required, single worker +-- so this is a bound that would matter on first deployment, not a live defect. + +The tests drive the FULL app built by `create_app`, not a bare ASGI stack, because the claim is about +where the middleware sits: outside the auth dependencies and the body cap, inside the client-network +gate. A hand-built stack would pass while the registration was wrong. +""" + +from __future__ import annotations + +import asyncio +import warnings + +import pytest +from starlette.testclient import TestClient + +from messagefoundry.api import create_app +from messagefoundry.api.request_timeout import ( + DEFAULT_REQUEST_TIMEOUT_SECONDS, + TIMEOUT_STATE_ATTR, +) + +#: Long enough that no scheduling hiccup finishes it inside the deadline, short enough that the RED +#: run is not a wait: the deadlines below are 0.05-0.2s. +_SLOW_SECONDS = 3.0 + + +def _app_with_a_slow_route(timeout_seconds: float | None) -> tuple[object, list[str]]: + """The real app plus one deliberately slow route, and a list that records whether the handler + ran to completion (so a 503 can be told apart from a handler that quietly finished).""" + app = create_app() + finished: list[str] = [] + + @app.get("/_test/slow") + async def _slow() -> dict[str, str]: + await asyncio.sleep(_SLOW_SECONDS) + finished.append("slow") + return {"status": "finished"} + + @app.get("/_test/fast") + async def _fast() -> dict[str, str]: + finished.append("fast") + return {"status": "ok"} + + if timeout_seconds is not None: + app.state.request_timeout_seconds = timeout_seconds + return app, finished + + +def _client(app: object) -> TestClient: + with warnings.catch_warnings(): # the starlette<->httpx TestClient deprecation warning is noise + warnings.simplefilter("ignore") + return TestClient(app) # type: ignore[arg-type] + + +def test_a_slow_handler_is_refused_with_a_bounded_error() -> None: + """The bound itself. Mutation: remove the `RequestTimeoutMiddleware` registration from + `create_app`. Red: 200 with `{"status": "finished"}` after the handler ran to completion.""" + app, finished = _app_with_a_slow_route(0.1) + with _client(app) as client: + response = client.get("/_test/slow") + assert response.status_code == 503, ( + f"a handler that never responded returned {response.status_code}, not a bounded error" + ) + assert response.json() == {"detail": "the server timed out building a response"} + assert finished == [], "the handler must be cancelled, not merely reported on" + + +def test_the_refusal_carries_the_baseline_security_headers() -> None: + """The middleware sits OUTSIDE the app's `_security_headers`, so a refusal short-circuits it. If + it did not set them itself the 503 would be the one response in the API with none of them. + + Mutation: drop `_TIMEOUT_HEADERS`. Red: the missing header is named.""" + app, _ = _app_with_a_slow_route(0.1) + with _client(app) as client: + response = client.get("/_test/slow") + assert response.status_code == 503 + for header, value in ( + ("X-Content-Type-Options", "nosniff"), + ("Referrer-Policy", "no-referrer"), + ("X-Frame-Options", "DENY"), + ("Cache-Control", "no-store"), + ): + assert response.headers.get(header) == value, f"the 503 is missing {header}" + + +def test_a_fast_handler_is_untouched() -> None: + """Live positive control: with the SAME deadline in force, a handler that answers promptly + returns its own response. Without this, a middleware that 503'd everything would pass the bound + test above.""" + app, finished = _app_with_a_slow_route(0.1) + with _client(app) as client: + response = client.get("/_test/fast") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + assert finished == ["fast"] + + +def test_the_deadline_is_on_the_route_not_only_on_unauthenticated_paths() -> None: + """The deadline must cover a real engine route, not just the test route bolted on above -- i.e. + the middleware is genuinely outside the auth dependencies rather than inside the router. + + `/status` requires auth and, with no auth service attached, fails closed. What is asserted here + is only that a real route still answers under a deadline in force: a middleware that swallowed + or delayed authenticated routes would show up as a 503 instead of the fail-closed status.""" + app, _ = _app_with_a_slow_route(5.0) + with _client(app) as client: + assert client.get("/health").status_code == 200 + assert client.get("/status").status_code in (401, 403, 503) + + +def test_a_disabled_deadline_lets_a_slow_handler_finish() -> None: + """`<= 0` disables the deadline. This is the escape hatch a deployment with a genuinely long + admin operation uses, and it must actually disable rather than clamp to some floor.""" + app, finished = _app_with_a_slow_route(0.0) + with _client(app) as client: + response = client.get("/_test/fast") + assert response.status_code == 200 + assert finished == ["fast"] + + +def test_the_deadline_sits_inside_the_network_gate_and_outside_everything_else() -> None: + """The registration order is the claim, so it is pinned rather than described in a comment. + + `add_middleware` inserts at index 0 and the stack is built from `reversed(user_middleware)`, so + index 0 is OUTERMOST. `ClientNetworkMiddleware` must stay there — a refused address is rejected + before it can occupy a deadline — and the timeout must be next, i.e. outside the attachment CSP + re-assert, the console's own middleware, the body cap, the security-headers middleware and every + auth dependency. It must also be pure ASGI: a `BaseHTTPMiddleware` here would add a task hop and + hide the response-start signal the deadline is cancelled on.""" + from starlette.middleware.base import BaseHTTPMiddleware + + from messagefoundry.api.client_networks import ClientNetworkMiddleware + from messagefoundry.api.request_timeout import RequestTimeoutMiddleware + + app = create_app() + registered = [m.cls for m in app.user_middleware] + assert registered[0] is ClientNetworkMiddleware, "the network gate must stay outermost" + assert registered[1] is RequestTimeoutMiddleware, ( + f"the request deadline must sit directly inside the network gate; the stack is {registered}" + ) + assert registered.count(RequestTimeoutMiddleware) == 1 + assert not issubclass(RequestTimeoutMiddleware, BaseHTTPMiddleware) + + +def test_the_shipped_default_is_a_backstop_not_a_latency_budget() -> None: + """A default so tight that ordinary admin work tripped it would be a self-inflicted outage, and + one so loose it never fires is not a control. Pinned so a future edit is a decision.""" + assert DEFAULT_REQUEST_TIMEOUT_SECONDS == 120.0 + assert TIMEOUT_STATE_ATTR == "request_timeout_seconds" + + +@pytest.mark.parametrize("attr_value", [None, "not-a-number"]) +def test_an_unusable_state_value_falls_back_to_the_default(attr_value: object) -> None: + """The override is read off `app.state` with a default. A missing or non-numeric value must + leave the shipped deadline in force -- never disable the control, and never crash the request.""" + app, finished = _app_with_a_slow_route(None) + if attr_value is not None: + app.state.request_timeout_seconds = attr_value # type: ignore[attr-defined] + with _client(app) as client: + response = client.get("/_test/fast") + assert response.status_code == 200 + assert finished == ["fast"] From ed133648605eb2d28479a18e8b70e1346615085e Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 10:11:08 -0500 Subject: [PATCH 17/28] test(cutover): the mirror-prose ratchet counted the backlog archive but not the backlog (BACKLOG #327) Archiving 41 closed items took test_present_tense_mirror_prose_does_not_grow from 54 to 55 and red, with ZERO new prose written. Cause: _HISTORICAL excluded docs/BACKLOG.md but not docs/archive/backlog/BACKLOG-CLOSED.md, so the same sentence counted or did not purely by which of the two ledger files it sat in -- and closing an item moves the block byte-identically from the excluded file into the counted one. Any archival pass could breach this ratchet. It is also unfixable where it lands. The archive's invariant is that each block is byte-identical to the one that left BACKLOG.md, which is what keeps its #- anchors resolving. So an archive hit can be counted forever and never edited away. A ratchet that can fire and can never be cleared is one that eventually gets suppressed -- the outcome this module's own docstring warns about. VERIFIED BEFORE EXCLUDING, because excluding a file to hide a real hit is the failure mode here. All three archive hits are false positives of the kind already recorded above the regex: :1558 "0 downloads on a private repo" a DIFFERENT project's release channel :4744 "green on the mirror-nightly run" a CI job name :7470 "the mirror branch does not exist" a git branch in the scorecard repo None claims this repository is a mirror. No genuine hit is lost. CEILING LOWERED 54 -> 52, not left at 54 to bank the two. Honest count re-measured at 52 across 1533 tracked files. Banking slack is what turns a ratchet into a rubber stamp: rot would have to exceed the slack before anything reds, and nothing reports that the gate has gone quiet. PROVED STILL ABLE TO FIRE rather than assumed. Planting one genuine present-tense claim ("This repository is the public mirror of the private development repo") in a SCANNED file takes it red at 53 against the new ceiling of 52; removed, green again, the file byte-clean. Narrower domain, tighter threshold, still fires on the first new instance. SCOPE NOTE: this is the only file outside the ledger lane's list that this wave touched. It is here because the archive move surfaced the defect and the branch is otherwise red; the alternative -- editing the moved prose -- would have broken the verbatim invariant the whole archive rests on, and raising the ceiling is forbidden by this module in as many words. --- tests/test_cutover_slug_rot.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/test_cutover_slug_rot.py b/tests/test_cutover_slug_rot.py index d1c02fde..869bc471 100644 --- a/tests/test_cutover_slug_rot.py +++ b/tests/test_cutover_slug_rot.py @@ -47,11 +47,32 @@ _PUBLIC = "MEFORORG/MessageFoundry" #: Records by construction — a changelog/ADR/backlog entry SHOULD describe the topology of its day. +#: +#: ``docs/archive/backlog/BACKLOG-CLOSED.md`` was MISSING here, and the omission made this ratchet +#: breachable by an ordinary archival pass with no new prose written (BACKLOG #327/#1099 wave, +#: 2026-08-10). Closing an item moves its block **byte-identically** out of ``docs/BACKLOG.md`` — +#: which this tuple already excludes — into the archive, which it did not. So the same sentence +#: counted or did not purely by which of the two ledger files it sat in, and moving 41 closed blocks +#: took the count 54 -> 55 and failed CI on a documentation-only branch. That is the same shape as the +#: ``private repo`` substring false positives recorded below: a hit that is not rot. +#: +#: It is also UNFIXABLE where it lands. The archive's stated invariant is that every block is +#: byte-identical to the one that left ``BACKLOG.md``, which is what keeps its ``#-`` anchors +#: resolving — so an archive hit can never be edited away, only counted. A ratchet that can fire and +#: can never be cleared is a gate that must eventually be suppressed, which is the outcome this +#: module's own docstring warns about. Excluding the archive is the honest form of the same rule +#: ``docs/BACKLOG.md`` is already here for. +#: +#: Verified before changing (2026-08-10): the three archive hits are all false positives of the +#: existing kind — "0 downloads on a private repo" (a DIFFERENT project's release channel), "the +#: mirror-nightly run" (a CI job name), and "the mirror branch does not exist" (a git branch in the +#: scorecard repo). None claims this repository is a mirror. No genuine hit is lost. _HISTORICAL = ( "CHANGELOG.md", "docs/adr/", "docs/releases/", "docs/BACKLOG.md", + "docs/archive/backlog/BACKLOG-CLOSED.md", "docs/reviews/", "docs/benchmarks/results", ) @@ -81,7 +102,13 @@ #: 2026-07-30: 55 -> 54. Not slack being taken: the ``private repo`` word-boundary fix above removed #: two SUBSTRING false positives, so the honest count fell and the ceiling follows it DOWN. Measured #: at 54 across 1412 tracked files. The rule is unchanged — this number may fall again, never rise. -_PROSE_CEILING = 54 +#: +#: 2026-08-10: 54 -> 52, and this one is worth reading as a WARNING about the number itself. The +#: archive exclusion added to ``_HISTORICAL`` above dropped three false positives, so the honest count +#: fell to 52 across 1533 tracked files and the ceiling follows it DOWN. **The ceiling is NOT left at +#: 54 to bank the two.** Slack is what turns a ratchet into a rubber stamp: real rot would have to +#: exceed the slack before anything reds, and nothing would report that the gate had gone quiet. +_PROSE_CEILING = 52 #: This module, excluded from its own scan. Its taxonomy above necessarily SPELLS every phrase it From e3cecf8797b29695dfa9a09cf66cc7689a35a506 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 10:42:58 -0500 Subject: [PATCH 18/28] refactor(file): drop the orphaned _unique helper (BACKLOG #1046) Routing the archive move through `_claim_unique` left `_unique` with no callers in the module. It is not merely dead: it is the exists()-then-act helper the fix exists to retire, so leaving it in the file invites the next caller straight back onto the racy form. Verified orphaned rather than assumed -- a tree-wide search for the name finds only `harness/file_transport.py`'s own module-level function and `transports/remotefile.py`'s own method, neither of which imports this one, plus prose references in the #1046 docstring and test module. It was never in `__all__`. --- messagefoundry/transports/file.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/messagefoundry/transports/file.py b/messagefoundry/transports/file.py index 77cca870..d4c7b88e 100644 --- a/messagefoundry/transports/file.py +++ b/messagefoundry/transports/file.py @@ -905,18 +905,5 @@ def _mtime(p: Path) -> float: return 0.0 -def _unique(target: Path) -> Path: - """Return ``target`` or, if it exists, ``name-1.ext``, ``name-2.ext``, …""" - if not target.exists(): - return target - stem, suffix = target.stem, target.suffix - n = 1 - while True: - candidate = target.with_name(f"{stem}-{n}{suffix}") - if not candidate.exists(): - return candidate - n += 1 - - register_destination(ConnectorType.FILE, FileDestination) register_source(ConnectorType.FILE, FileSource) From 0cbb0d9498aee82e3993027c5bb3ad59b931f2ca Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 10:48:18 -0500 Subject: [PATCH 19/28] test: BACKLOG #1043 - keep the mirror-prose ratchet at its ceiling The #1043 commit added one line of new "OSS mirror" prose to the module docstring, which took tests/test_cutover_slug_rot.py's present-tense ratchet from 54 to 55 against a ceiling of 54. Measured, not assumed: the same five modules run at origin/main fail 20 tests in this environment and on the branch 21, and the one difference is exactly test_present_tense_mirror_prose_does_not_grow. The other 20 are pre-existing here (installed-hook parity, dependabot allowset, workflow shell syntax) and reproduce unchanged at the base commit. Reworded rather than ceiling-raised - raising it is the rot the ratchet exists to stop. The warning text loses the same framing for a second reason: the repository is developed directly in the public remote now, so "deny-listed from the OSS mirror" is dead framing to be planting in new prose. "withheld from public checkouts" is what is actually true, and it is what an operator reading the warning needs. --- tests/test_threat_model_doc_drift.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_threat_model_doc_drift.py b/tests/test_threat_model_doc_drift.py index 552a2dd5..4b091178 100644 --- a/tests/test_threat_model_doc_drift.py +++ b/tests/test_threat_model_doc_drift.py @@ -35,8 +35,8 @@ review checklist carries the "does this change introduce a new dangerous-functionality or resource-demanding surface?" question for that half. -**Where the document is not present** (BACKLOG #1043). ``docs/security/**`` is deny-listed from the -OSS mirror and vaulted, so on a public checkout the doc-CONTENT assertions have nothing to assert. +**Where the document is not present** (BACKLOG #1043). ``docs/security/**`` is withheld from public +checkouts (deny-listed and vaulted), so there the doc-CONTENT assertions have nothing to assert. They used to answer that with a bare ``pytest.skip``, which in a 12,000-test run is indistinguishable from a pass — a control that cannot report its own inertness (ADR 0158's class 2). Three changes: @@ -291,8 +291,8 @@ def _announce_absence(path: Path) -> None: _ANNOUNCED = True warnings.warn( ThreatModelDocUnenforced( - f"{path} is absent from this checkout (docs/security/** is deny-listed from the OSS " - "mirror and vaulted), so EVERY doc-content assertion in " + f"{path} is absent from this checkout (docs/security/** is withheld from public " + "checkouts — deny-listed and vaulted), so EVERY doc-content assertion in " "tests/test_threat_model_doc_drift.py is INERT in this run: the heading/table structure, " "the 15.1.3 and 15.1.5 anchor registries, the planted-omission self-tests over the real " "document, the setting-name resolution, the doc side of the numeric parity loop, and the " From 05e252fba042a4190408610bc80af31c397b09da Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 11:33:03 -0500 Subject: [PATCH 20/28] fix(coord): the announce hook's SKIP rule reads isRunning backwards (BACKLOG #1077) The hook told every session "No exact row, or isRunning is false -> SKIP that peer", then had the model file the outcome under the token NOT_RUNNING. Measured 2026-08-06: an isRunning:false peer was DELIVERED to and answered within one turn, while an isRunning:true peer QUEUED behind its in-flight turn. The field means "executing a turn right now", so as a reachability test it reads backwards -- the rule dropped exactly the peers most able to answer, and the receipt token recorded a delivery that would have succeeded under a word that reads "gone". scripts/coord/session-registry.ps1 already documented the correct reading, so the repo contradicted itself in the one place a model acts on it. - Drop the isRunning condition entirely. An exact cwd match is the whole test; a wrong id is loud, so cwd alone deciding costs nothing. - Drop the NOT_RUNNING token from the delivery-receipt vocabulary, leaving two. The rationale is a source comment, not a line of stdout: naming a retired token in the instruction is how a model learns it is available. - Record the measurement ONCE, in session-registry.ps1's header (the field's source of record). The hook and docs/WORKTREES.md now point at it instead of restating. - Correct the WORKTREES.md paragraph that counted isRunning:true for 1 of 6 registry-LIVE peers and read that as a reachability rate; it was a count of who happened to be mid-turn. Tests assert the EMITTED STRING, because the hook's entire product is the text it puts in front of the model. Watched both go RED against the pre-fix script first: the failure output quoted "_LISTED | NOT_RUNNING> TAB " as what it scanned. The absence assertion is paired with a presence assertion so a hook that emitted no instruction at all could not satisfy it -- and it immediately earned its keep by failing an earlier draft of this change whose own explanatory line printed the retired token. --- docs/WORKTREES.md | 15 ++++++--- scripts/coord/session-registry.ps1 | 13 ++++++++ scripts/hooks/announce-session.ps1 | 14 +++++++-- tests/test_announce_hook.py | 50 ++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 6 deletions(-) diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index 2e40ef98..4f8c4538 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -551,10 +551,17 @@ per session. It stays silent, and keeps its powder dry, when there's nobody to t resume mints a new session id, so a 30-minute per-checkout cooldown suppresses the immediate re-announce. **Expect about half the roster to be unreachable.** `presence.ps1` is authoritative for who **exists**; -`list_sessions` is authoritative only for who can be **messaged**, and the two disagree. Measured -2026-08-01: of 6 registry-LIVE peers, `list_sessions` reported `isRunning: true` for one. The hook cannot -call MCP and so cannot filter on that, which is why the cap is a budget of *delivered* messages the model -tops up past unreachable peers, rather than a candidate list the hook trims. +`list_sessions` is authoritative only for who can be **messaged**, and the two disagree. The cap is +therefore a budget of *delivered* messages the model tops up past unreachable peers, rather than a +candidate list the hook trims. + +**Reachability is an exact `cwd` match and nothing else — never `isRunning`.** That flag means *"executing +a turn right now"*, so as a reachability test it reads **backwards**: `false` is an idle peer that answers, +`true` is one that queues. The field, its measurement and the cross-surface caveat are recorded once, in +[`scripts/coord/session-registry.ps1`](../scripts/coord/session-registry.ps1)'s header. The announce hook +instructed every session to skip on `isRunning: false` until **BACKLOG #1077**; an earlier reading of this +paragraph counted `isRunning: true` for 1 of 6 registry-LIVE peers and treated that as a reachability +rate, when it was a count of who happened to be mid-turn. **State, receipts and the kill switch.** `/mefor-coord/announce/` holds one `.json` marker per session (delete it to force a re-announce), `receipts/.tsv` — one diff --git a/scripts/coord/session-registry.ps1 b/scripts/coord/session-registry.ps1 index d5092d43..a447371f 100644 --- a/scripts/coord/session-registry.ps1 +++ b/scripts/coord/session-registry.ps1 @@ -63,6 +63,19 @@ session is currently EXECUTING A TURN", not "this session is alive" -- an idle session between turns reads false while being perfectly reachable. It is not a liveness fence and must not be used as one; that is what Get-SessionLiveness above is for, subject to the positive-answer-only rule. + + THIS IS THE SOURCE OF RECORD FOR THAT FIELD, so the measurement lives here rather than being + restated in each consumer. Measured 2026-08-06 by sending and getting real replies, not inferred + from the field: an `isRunning: false` peer was DELIVERED to and answered within one turn, while an + `isRunning: true` peer returned "Message queued ... will be processed after the in-flight turn + finishes". As a REACHABILITY test the field therefore reads BACKWARDS, and filtering on it drops + exactly the peers most able to answer. announce-session.ps1 instructed every session to do that + until BACKLOG #1077. + + AND IT IS NOT UNIFORM ACROSS SURFACES, which is why the safe reading is "not observable" rather + than "idle": a VS Code session is never entered into the Desktop app's in-memory session map at + all, so it is ABSENT rather than listed-and-quiet, and no value of `isRunning` describes it. The + field reports what the Desktop app can observe, and reachability is decided by cwd, not by it. #> # Every config root that actually holds a session registry. diff --git a/scripts/hooks/announce-session.ps1 b/scripts/hooks/announce-session.ps1 index 667bd86b..3a79a9a1 100644 --- a/scripts/hooks/announce-session.ps1 +++ b/scripts/hooks/announce-session.ps1 @@ -576,7 +576,11 @@ try { $lines += ' extension of the primary checkout''s path, so a prefix match resolves a peer in' $lines += ' the primary to some arbitrary worktree session. Measured here: the two rosters' $lines += ' print byte-identical cwds, so an exact match is expected to succeed.' - $lines += ' No exact row, or isRunning is false -> SKIP that peer. Never guess an id.' + $lines += ' No exact row -> SKIP that peer. Never guess an id. The cwd match is the WHOLE' + $lines += ' test: DO NOT also filter on isRunning. It means "executing a turn right now",' + $lines += ' not "alive" or "reachable", so as a reachability signal it reads BACKWARDS --' + $lines += ' false is an idle peer that answers, true is one that queues. Source of record' + $lines += ' for the field, with the measurement: scripts\coord\session-registry.ps1.' $lines += '3. send_message to the sessionId from that row. It MUST start with ''local_''.' $lines += ' The 8-character id in this repo''s coordination banners is the REGISTRY id, a' $lines += ' different namespace: measured here, a registry id and an MCP id for ONE session' @@ -592,7 +596,13 @@ try { $lines += ' touching: ' $lines += ' It lands as a USER turn in their session. Ask nothing, expect no answer.' $lines += "5. Append one line per peer to $StateDir/sent/$markerKey.tsv :" - $lines += ' TAB TAB TAB ' + # TWO tokens, not three. The third used to be NOT_RUNNING, and it went with the isRunning SKIP + # rule above (BACKLOG #1077): it filed a peer that would have ACCEPTED the message under a word + # that reads "gone". The rationale is a comment and not a line of stdout on purpose -- naming a + # retired token in the instruction is how a model learns it is available. tests/ + # test_announce_hook.py asserts the whole announcement is free of it, which is what caught an + # earlier draft of this very comment printing it. + $lines += ' TAB TAB TAB ' $lines += ' Nothing else records whether anything was delivered.' $lines += '' $claims = Get-ClaimNotes (Join-Path (Split-Path $StateDir -Parent) 'claims') diff --git a/tests/test_announce_hook.py b/tests/test_announce_hook.py index f778cd64..be2e68c7 100644 --- a/tests/test_announce_hook.py +++ b/tests/test_announce_hook.py @@ -258,6 +258,56 @@ def test_the_peer_line_carries_the_full_cwd_and_forbids_prefix_matching( assert "DO NOT PREFIX-MATCH" in p.stdout +# -------------------------------------------------------------------------------------------------- +# BACKLOG #1077 -- the SKIP rule and its receipt vocabulary. +# +# These assert the EMITTED STRING, not a code path, and that is deliberate: the hook's entire product is +# the text it puts in front of the model. A defect here cannot be observed any other way, and a test that +# exercised a branch instead of reading the words would have passed throughout the defect's whole life. +# -------------------------------------------------------------------------------------------------- + + +def test_the_skip_rule_turns_on_the_cwd_match_and_not_on_isrunning( + repo: Path, tmp_path: Path +) -> None: + """``isRunning`` means "executing a turn right now", so as a REACHABILITY test it reads backwards. + + Measured 2026-08-06: an ``isRunning: false`` peer was delivered to and answered within one turn, + while an ``isRunning: true`` peer queued behind its in-flight turn. The hook nonetheless told every + session to skip on ``false``, which drops exactly the peers most able to answer -- and it did so + while ``scripts/coord/session-registry.ps1`` already documented the opposite reading, so the repo + contradicted itself in the one place a model would act on it. + + Asserted as an ABSENCE plus a PRESENCE. The absence alone would be satisfied by a hook that emitted + no instruction at all, which is why the surviving rule is pinned in the same test. + """ + sd = tmp_path / "state" + p = run(repo, tmp_path=tmp_path, state_dir=sd, rows=[SELF_ROW, PEER]) + assert "isRunning is false -> SKIP" not in p.stdout, ( + "the hook still teaches isRunning as a disqualifier" + ) + assert "No exact row -> SKIP that peer." in p.stdout + assert "DO NOT also filter on isRunning" in p.stdout + # The rule must say WHY, or the next reader re-adds it. Naming the source of record is what keeps + # the measurement in one place instead of drifting across three restatements. + assert "session-registry.ps1" in p.stdout + + +def test_the_delivery_receipt_vocabulary_has_no_not_running_token( + repo: Path, tmp_path: Path +) -> None: + """``NOT_RUNNING`` recorded a delivery that would have succeeded, under a token that reads "gone". + + The token is the half of #1077 that outlives the rule: drop the SKIP condition and leave the + vocabulary, and a session still has a slot to file a reachable peer into -- so the receipts keep + reporting phantom dead peers and nothing in the file says the rule changed. + """ + sd = tmp_path / "state" + p = run(repo, tmp_path=tmp_path, state_dir=sd, rows=[SELF_ROW, PEER]) + assert "NOT_RUNNING" not in p.stdout + assert "TAB TAB " in p.stdout + + # -------------------------------------------------------------------------------------------------- # Silence, and the marker that must NOT be burned. # -------------------------------------------------------------------------------------------------- From 817953d6e909832db5e07b59c5eb03ca466a70c0 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 11:37:49 -0500 Subject: [PATCH 21/28] fix(coord): label the session id that reads as a commit SHA (BACKLOG #1098) The SessionStart banner printed each live peer as a bare 8-hex token before a bracketed branch. That is a REGISTRY SESSION ID and resolves to no git object -- but the banner's own `git worktree list` block, a few lines above, prints a REAL abbreviated SHA in exactly that shape, so the reader is taught the wrong meaning by the output itself. A session comparing "is that worktree ahead of mine" against it gets a git error at best, and the wrong tree if the prefix happens to resolve. Measured in the test, not asserted: the same run carries ` 8ba9b65 [master]` rows from `git worktree list`, keyed to `rev-parse HEAD` so the control is an object and not a shape. - session-context.ps1: the word `session` goes on the ROW, in both the live-peer roster and the "WHAT THEY ARE BUILDING" list. A legend would let the column mean one thing in one row and something else in the next, which is what the item forbids. - presence.ps1: a column HEADER instead, because that output is a fixed-width table -- one header governs every row, which is the same property. It names all four columns; naming only the ambiguous one leaves the rest unstated. Both tests assert the EMITTED TEXT, which is the only place either defect exists: every other test of these two scripts reads `-Json`, where the field is called `Short` and no ambiguity is possible. That is exactly how a reporting defect survives a green suite. Watched both go RED against the pre-fix scripts first; the presence failure printed the whole table it scanned. No consumer parses the human table -- mail.ps1, announce-session.ps1 and session-context.ps1 all invoke presence with `-Json`, which returns before it. --- scripts/coord/presence.ps1 | 7 ++++ scripts/worktree/session-context.ps1 | 13 ++++++- tests/test_coord_presence.py | 54 ++++++++++++++++++++++++++ tests/test_session_context_presence.py | 54 ++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 2 deletions(-) diff --git a/scripts/coord/presence.ps1 b/scripts/coord/presence.ps1 index a585c5ea..51a8e158 100644 --- a/scripts/coord/presence.ps1 +++ b/scripts/coord/presence.ps1 @@ -190,6 +190,13 @@ if ($rows.Count -eq 0) { Write-Host "" Write-Host "Live Claude sessions in this repo ($($rows.Count)):" +# NAME EVERY COLUMN (BACKLOG #1098). The leading token is an 8-hex REGISTRY SESSION ID and reads exactly +# like an abbreviated commit SHA: session-context.ps1's banner, which points readers here, prints a real +# one in the same shape (`git worktree list` = path, sha, branch). Unlabelled, a reader resolves it +# against git and gets an error at best, the wrong tree if the prefix happens to resolve. A header row +# rather than a per-row word because this is a fixed-width table -- the header governs every row, which +# is the property the item asks for. +Write-Host (" {0,-8} {1,-7} {2,-34} {3}" -f "sess-id", "surface", "worktree", "branch") foreach ($r in $rows) { $me = if ($r.IsSelf) { " <-- THIS session" } else { "" } $warn = if ($r.IsPrimary -and -not $r.IsSelf) { " [in the SHARED PRIMARY]" } else { "" } diff --git a/scripts/worktree/session-context.ps1 b/scripts/worktree/session-context.ps1 index 37212a8d..6a9c12e2 100644 --- a/scripts/worktree/session-context.ps1 +++ b/scripts/worktree/session-context.ps1 @@ -72,10 +72,18 @@ if ($root) { if ($others.Count -gt 0) { $lines += "" $lines += "LIVE sessions in this repo right now ($($others.Count) besides you):" + # LABEL THE ID IN EVERY ROW (BACKLOG #1098). Bare, the 8-hex token reads as an + # abbreviated commit SHA -- and it reads that way for a concrete reason, not a vague + # one: the `git worktree list` block a few lines above puts a REAL abbreviated SHA in + # exactly this shape, before a bracketed branch. It is a registry session id, resolves + # to no git object, and a session that compared "is that worktree ahead of mine" + # against it got an error at best and the wrong tree if the prefix happened to resolve. + # The word goes on the ROW, not in a legend, so the column cannot mean one thing in one + # row and something else in the next. foreach ($p in $others) { $where = if ($p.IsPrimary) { "the SHARED PRIMARY" } else { $p.Worktree } $flag = if ($p.State -ne "LIVE") { " [$($p.State)]" } else { "" } - $lines += " $($p.Short) $($p.Surface) in $where [$($p.Branch)]$flag" + $lines += " session $($p.Short) $($p.Surface) in $where [$($p.Branch)]$flag" } # The surfaces differ in what can reach them, and that changes how you coordinate. if (@($others | Where-Object { $_.Surface -ne "desktop" }).Count -gt 0) { @@ -103,7 +111,8 @@ if ($root) { $lines += "" $lines += "WHAT THEY ARE BUILDING -- check before you start, so you don't build it twice:" foreach ($b in $busy) { - $lines += " $($b.Short) [$($b.Branch)] -- $(@($b.Files).Count) file(s) changed" + # Same id, same banner, same label -- see the note on the roster rows above. + $lines += " session $($b.Short) [$($b.Branch)] -- $(@($b.Files).Count) file(s) changed" foreach ($w in @($b.Work | Select-Object -First 3)) { $lines += " $w" } } $lines += " Everything in flight: pwsh -NoProfile -File scripts\coord\overlap.ps1" diff --git a/tests/test_coord_presence.py b/tests/test_coord_presence.py index 1d865f8f..83a1f08f 100644 --- a/tests/test_coord_presence.py +++ b/tests/test_coord_presence.py @@ -219,6 +219,60 @@ def test_malformed_record_does_not_break_the_roster(repo: Path, config_root: Pat assert [r["Short"] for r in rows] == ["12121212"] +def test_the_human_table_names_its_columns_so_the_id_cannot_read_as_a_sha( + repo: Path, config_root: Path +) -> None: + """BACKLOG #1098. The default (non-``-Json``) output is a fixed-width table with no header, whose + leading cell is an 8-hex REGISTRY SESSION ID -- the same shape ``git worktree list`` uses for an + abbreviated commit SHA, in a banner (``session-context.ps1``) that points readers straight here. + + Asserts the EMITTED TEXT, which is the only place this defect can exist: every other test in this + file reads ``-Json``, where the field is named ``Short`` and no ambiguity is possible. That is + precisely how a reporting defect survives a green suite. + """ + write_session(config_root, pid=os.getpid(), cwd=repo, session_id="abcdef01-1111") + proc = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(PRESENCE), + "-Repo", + str(repo), + "-ConfigRoot", + str(config_root), + ], + capture_output=True, + text=True, + timeout=120, + check=False, + ) + assert proc.returncode == 0, f"presence.ps1 failed: {proc.stderr}" + print(proc.stdout) + + lines = proc.stdout.splitlines() + header = next((ln for ln in lines if "sess-id" in ln), None) + assert header is not None, ( + f"the table prints no column header, so its leading 8-hex cell has no stated meaning:\n" + f"{proc.stdout}" + ) + assert "surface" in header and "worktree" in header and "branch" in header, ( + f"a header naming only the ambiguous column leaves the rest unnamed: {header!r}" + ) + + # The header must sit above the row it governs, or it labels a different table. + row = next(ln for ln in lines if "abcdef01" in ln) + assert lines.index(header) < lines.index(row) + # And the id must land UNDER its own column rather than merely somewhere on the line. A header + # whose cells do not line up with the data is a label pointing at the wrong value, which is the + # same class of defect this test exists to close. + assert row.index("abcdef01") == header.index("sess-id"), ( + f"header and row are not aligned, so the label points at the wrong cell:\n" + f"{header!r}\n{row!r}" + ) + + def _find_free_pid() -> int: """A pid that is not currently running -- start a process, note its pid, wait for it to exit.""" proc = subprocess.Popen( diff --git a/tests/test_session_context_presence.py b/tests/test_session_context_presence.py index d05b2fc1..ad92b505 100644 --- a/tests/test_session_context_presence.py +++ b/tests/test_session_context_presence.py @@ -145,6 +145,60 @@ def test_live_peers_are_rendered_when_presence_reports_them(staged: Path) -> Non assert "cannot be reached by" in proc.stdout # the non-desktop coordination note +def test_the_session_id_is_labelled_because_the_same_banner_prints_real_shas( + staged: Path, +) -> None: + """BACKLOG #1098: an 8-hex session id printed bare reads as an abbreviated commit SHA. + + It reads that way for a measured reason rather than a vague one, and this test measures it: the + ``git worktree list`` block a few lines up prints `` [branch]``, so the same + banner already teaches "8 hex before a bracketed branch = commit SHA". The roster then reused the + shape for a registry session id, which resolves to no git object at all. A session comparing "is that + worktree ahead of mine" against it gets an error at best, and the wrong tree if the prefix resolves. + + The POSITIVE CONTROL is the first half: assert the real SHA is genuinely in this output, keyed to + ``rev-parse HEAD`` so it is an object and not a shape. Without it the second assertion would be a + claim about an ambiguity nobody had shown to exist. + """ + head = subprocess.run( + ["git", "-C", str(staged), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + fake_presence( + staged, + [ + { + "Short": "deadbeef", + "Surface": "desktop", + "Worktree": "sibling", + "Branch": "second", + "IsSelf": False, + "IsPrimary": False, + "State": "LIVE", + } + ], + ) + proc = run_context(staged) + assert proc.returncode == 0 + + sha_rows = [ln for ln in proc.stdout.splitlines() if head[:7] in ln] + assert sha_rows, ( + f"no row carries HEAD ({head[:7]}), so this banner does not in fact print commit SHAs and the " + f"ambiguity below is unproven -- fix this control before trusting the assertion after it" + ) + print(f"real SHA rows in the same banner: {sha_rows}") + + row = next(ln for ln in proc.stdout.splitlines() if "deadbeef" in ln) + print(f"roster row: {row!r}") + assert row.strip().startswith("session deadbeef"), ( + f"the registry session id is printed bare, in the same shape the worktree rows use for a real " + f"abbreviated SHA: {row!r}" + ) + + def test_self_is_excluded_from_the_peer_count(staged: Path) -> None: fake_presence( staged, From 48c2d2f6963d3765477790034691115fa31149e0 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 11:45:12 -0500 Subject: [PATCH 22/28] fix(worktree): anchor install-gate's config-dir glob and stop it auditing its own writes (BACKLOG #1024) Two halves of one defect. 1. The glob was unanchored. `-Filter ".claude-account-*"` matches any directory whose name merely BEGINS with `.claude-account-`, and `~/.claude-account-2.lock` IS a directory -- so the installer wrote gate wiring into a dir with no .claude.json, no .credentials.json and no sessions, i.e. one nothing has ever launched from, and re-wrote it on every run. Now anchored on `\A\.claude-account- \d+\z`, the .NET spelling of the predicate #199 gave the Python reader (.NET `\Z` also matches before a trailing newline; `\z` is the one that means Python's `\Z`). Writer and reader are now the same predicate. 2. -Status validated its own output. It scanned exactly $ConfigDir -- the set this script WRITES -- so a wrong discovery predicate made the writer manufacture the wiring and the reader read it back as correct. Both were wrong the same way, so they agreed: a validator satisfied by construction, ADR 0158's exact class. -Status now enumerates a DIFFERENT population: every ~/.claude* dir carrying a settings.json, judged by name afterwards rather than selected by name up front. It prints that population by name (a count that got smaller looks like an improvement; only the names say what stopped being looked at) and reports any dir outside the wire set that still carries gate wiring as ORPHAN GATE WIRING. Reported, never fixed -- deliberately. Anchoring the writer also puts the existing artifact out of -Uninstall's reach, so the remedy is a command a human runs (`-Uninstall -ConfigDir ""`); which dir is a stale artifact versus a config root this box really uses is the owner's call. Verification is by redirected HOME, not by installing: a session must NOT execute this installer for real, because it rewrites user-scope wiring for every session on the box. That constraint is why the item was scored difficulty 3, and it is why -Status sits above the CLAUDECODE refusal -- auditing is not installing. Three tests, all watched RED against the pre-fix script first; the failure output carried the whole pre-fix -Status text, showing `.claude-account-2.lock` on a `wiring :` line and `scanned 3 config dir(s)`. - The writer/reader agreement test runs the REAL installer over the same name corpus the reader's own negative control uses and IMPORTS the reader's pattern rather than restating it. A restatement would be a third predicate, and this defect was two predicates disagreeing. It guards its own guard: the corpus must contain both an accepted and a rejected name, or the comparison is vacuous. - The orphan report is exercised on a decoy `.claude-account-2.lock`. - And the quiet arm is its negative control: same decoy, no settings.json, so the loud line must not fire. A line that appears on every run is one readers skip, which is how a real orphan would go unnoticed. --- docs/WORKTREE-GATE.md | 7 +- scripts/worktree/install-gate.ps1 | 119 +++++++++++++++++++++++-- tests/test_install_gate_wiring.py | 140 ++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+), 9 deletions(-) diff --git a/docs/WORKTREE-GATE.md b/docs/WORKTREE-GATE.md index 27573866..74eea37b 100644 --- a/docs/WORKTREE-GATE.md +++ b/docs/WORKTREE-GATE.md @@ -164,7 +164,12 @@ confirm every config dir shows hook entries. Two structural choices worth understanding: - **Every config dir, not just `~/.claude`.** The hook is registered into `~/.claude/settings.json` **and** - every `~/.claude-account-*/settings.json` (the VS Code launchers that set `CLAUDE_CONFIG_DIR`), mirroring + every `~/.claude-account-/settings.json` (the VS Code launchers that set `CLAUDE_CONFIG_DIR`) — that + name shape **exactly**, anchored on a decimal N, because an unanchored `.claude-account-*` also matched + `~/.claude-account-2.lock`, a directory the installer then wrote gate wiring into on every run while the + wiring check read it back as evidence (BACKLOG #1024). `-Status` now enumerates `~/.claude*` + independently of that predicate, so its audit cannot agree with the writer by construction, and it names + any dir outside the wire set that still carries gate wiring. This mirrors what `install-selfheal.ps1` already does. This is not a detail: the gate originally wired only `~/.claude`, which left every account-N session **ungated** — and those are where the parallel VS Code chats run. A session under an ungoverned account then checked its own branch out inside another session's diff --git a/scripts/worktree/install-gate.ps1 b/scripts/worktree/install-gate.ps1 index 53d1833c..4b4940b1 100644 --- a/scripts/worktree/install-gate.ps1 +++ b/scripts/worktree/install-gate.ps1 @@ -84,11 +84,49 @@ $ReposFile = Join-Path $HooksDir "worktree-gate.repos.txt" # Marker so we can find (and remove) exactly the entries we added, without disturbing other hooks. $Marker = "worktree_gate.ps1" -# Config dirs to wire. Default: ~/.claude + every existing ~/.claude-account-* (the VS Code launchers). +# The NAME shape of a launcher config dir, and the ONLY thing this script wires. ~/.claude is the Desktop +# app; every VS Code launcher on this box points CLAUDE_CONFIG_DIR at ~/.claude-account- with N decimal +# -- not inferred from a directory listing but from how the launchers BUILD the path +# (~/claude-launchers/Launch-Claude-{1..4}.ps1 assign a literal `.claude-account-`). A suffix after the +# number is therefore not an account, because nothing can launch from one. +# +# ANCHORED, and the anchors are the entire predicate (BACKLOG #1024). The old filter was +# `-Filter ".claude-account-*"`, which matches any name merely BEGINNING with `.claude-account-`. +# Measured 2026-08-04: `~/.claude-account-2.lock` IS a directory, so the filter matched it and this +# installer wrote gate wiring into it on every run -- into a dir with no `.claude.json`, no +# `.credentials.json` and no sessions, i.e. one nothing has ever launched from. +# +# THIS IS THE WRITER. The Python reader (tests/test_gate_installed_parity.py) used the same unanchored +# glob and read that wiring back as evidence the wiring was correct; the two agreed because both were +# wrong the same way. #199 anchored the reader as `\A\.claude-account-\d+\Z`; this is the matching half, +# so the predicate is now the same on both sides. +# +# `\z`, NOT `\Z`. Python's `\Z` is the absolute end of the string; .NET's `\Z` also matches BEFORE a +# trailing newline, and .NET's `\z` is the one that means what Python's `\Z` means. Spelling it `\Z` here +# would look like the reader and mean something slightly wider. +# +# Case-SENSITIVE, matching the reader. `-Filter` is case-insensitive on Windows, so a `.Claude-Account-2` +# would reach this predicate and be rejected -- leaving that dir unwired. That direction is deliberate: +# the -Status audit below enumerates independently of this predicate and reports any such dir by name, +# which is a louder outcome than silently wiring something the reader would refuse to judge. +$LauncherName = [regex]'\A\.claude-account-\d+\z' + +# Every ~/.claude* directory carrying a settings.json, WITHOUT judging what it is. Deliberately wider than +# the wire set: it is the -Status audit's independent population, and it must not be selected by the same +# predicate whose correctness it exists to check. +function Get-ConfigCandidates([string]$Root) { + @( + Get-ChildItem -LiteralPath $Root -Directory -Filter ".claude*" -Force -ErrorAction SilentlyContinue | + Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "settings.json") -PathType Leaf } + ) +} + +# Config dirs to wire. Default: ~/.claude + every existing ~/.claude-account- (the VS Code launchers). if (-not $ConfigDir -or $ConfigDir.Count -eq 0) { $cands = @( (Join-Path $HomeDir ".claude") ) $cands += @( Get-ChildItem -LiteralPath $HomeDir -Directory -Filter ".claude-account-*" -ErrorAction SilentlyContinue | + Where-Object { $LauncherName.IsMatch($_.Name) } | ForEach-Object { $_.FullName } ) $ConfigDir = @($cands | Where-Object { Test-Path -LiteralPath $_ -PathType Container }) @@ -128,6 +166,20 @@ function Remove-GateHooks($Data) { return $Data } +# Tool names reachable through a PreToolUse entry whose command names the gate. ONE reader, used by both +# the wire-set scan and the independent audit below -- two copies would drift, and the copy that drifts is +# the one that decides whether an orphan is reported. +function Get-WiredMatchers([string]$SettingsPath) { + $wired = [System.Collections.Generic.HashSet[string]]::new() + $s = Read-Settings $SettingsPath + foreach ($e in @($s.hooks.PreToolUse)) { + if (@($e.hooks) | Where-Object { "$($_.command)" -like "*$Marker*" }) { + foreach ($t in "$($e.matcher)".Split("|")) { if ($t) { $null = $wired.Add($t) } } + } + } + return $wired +} + function Get-GateVersion([string]$Path) { if (-not (Test-Path -LiteralPath $Path)) { return $null } $m = [regex]::Match((Get-Content -LiteralPath $Path -Raw), '\$GateVersion\s*=\s*"([^"]+)"') @@ -230,13 +282,7 @@ if ($Status) { $handled = @(Get-HandledTools $GateDst) foreach ($cd in $ConfigDir) { $sp = Join-Path $cd "settings.json" - $s = Read-Settings $sp - $wired = [System.Collections.Generic.HashSet[string]]::new() - foreach ($e in @($s.hooks.PreToolUse)) { - if (@($e.hooks) | Where-Object { "$($_.command)" -like "*$Marker*" }) { - foreach ($t in "$($e.matcher)".Split("|")) { if ($t) { $null = $wired.Add($t) } } - } - } + $wired = Get-WiredMatchers $sp # Rules that are deliberately unwired are reported as such, never as UNWIRED. A status line that # cries wolf about a known-and-intended state is one a reader learns to skip, which is how a real # UNWIRED would go unnoticed -- the exact failure this whole block exists to surface. @@ -257,6 +303,63 @@ if ($Status) { Write-Host " stray : $($stray -join ', ') <- matched but the script ignores it" -ForegroundColor Yellow } } + + # --- INDEPENDENT AUDIT: break the writer-validates-its-own-writing loop (BACKLOG #1024) ---------- + # Everything above scans $ConfigDir, which is the set this script WRITES. On its own that can only + # ever confirm the installer's own output: if the discovery predicate is wrong, the writer creates + # the wiring and the reader reads it back as evidence, and the two agree because they are the same + # predicate. That is a validator satisfied by construction (ADR 0158), and it is not hypothetical -- + # measured 2026-08-04, ~/.claude-account-2.lock held three gate matchers this installer had put + # there under the unanchored glob, and the Python reader counted them as correct wiring. + # + # So enumerate from a DIFFERENT starting point: every ~/.claude* directory that carries a + # settings.json, judged by name AFTERWARDS rather than selected by name up front. The names are + # printed whether or not anything is wrong, because a count that got smaller looks like an + # improvement and only the names say what stopped being looked at. + # + # Reported, never fixed. Anchoring the writer means -Uninstall no longer reaches an orphan either, + # so the remedy has to be a command a human runs deliberately -- and which dir is a stale artifact + # versus a config root this box really uses is the owner's call, not this script's. + $judged = @($ConfigDir | ForEach-Object { + try { (Resolve-Path -LiteralPath $_ -ErrorAction Stop).Path } catch { $_ } + }) + $seen = @(Get-ConfigCandidates $HomeDir) + Write-Host "" + Write-Host "audit : $($seen.Count) ~/.claude* dir(s) with a settings.json, enumerated independently" + Write-Host " of the wire set above (so this cannot agree with the writer by construction)" + Write-Host " found : $(@($seen | ForEach-Object { $_.Name } | Sort-Object) -join ', ')" + + $orphans = @() + $notJudged = @($seen | Where-Object { $judged -notcontains $_.FullName }) + foreach ($d in $notJudged) { + $why = if ($d.Name -ieq ".claude" -or $LauncherName.IsMatch($d.Name)) { + "outside the -ConfigDir set given on the command line" + } else { + "not a launcher name" + } + # An unreadable settings.json must not take -Status down over a directory nobody asked about, + # and must not read as "no wiring here" either. Say which it was. + $wired = $null + try { $wired = Get-WiredMatchers (Join-Path $d.FullName "settings.json") } catch { $wired = $null } + if ($null -eq $wired) { + Write-Host " UNREADABLE: $($d.Name) <- settings.json is not valid JSON; its wiring is unknown" -ForegroundColor Yellow + } elseif ($wired.Count -gt 0) { + $orphans += $d + Write-Host " ORPHAN GATE WIRING in $($d.Name) ($why)" -ForegroundColor Yellow + Write-Host " matched: $(@($wired | Sort-Object) -join ', ')" + Write-Host " This installer will neither refresh nor remove it. Remove it deliberately:" + Write-Host " install-gate.ps1 -Uninstall -ConfigDir `"$($d.FullName)`"" + } else { + Write-Host " not judged: $($d.Name) ($why) -- carries no gate wiring" + } + } + if ($notJudged.Count -eq 0) { + Write-Host " every dir found is in the wire set above; nothing is unjudged" + } + elseif ($orphans.Count -eq 0) { + Write-Host " no unjudged dir carries gate wiring" + } + Write-Host "" Write-Host "scanned $($ConfigDir.Count) config dir(s) against $(@($handled).Count) implemented rule(s)." return diff --git a/tests/test_install_gate_wiring.py b/tests/test_install_gate_wiring.py index 0e53aaf0..7329e085 100644 --- a/tests/test_install_gate_wiring.py +++ b/tests/test_install_gate_wiring.py @@ -13,6 +13,7 @@ from __future__ import annotations +import json import os import re import shutil @@ -145,6 +146,145 @@ def test_every_opt_in_tool_is_guarded_by_a_plain_switch() -> None: # ----------------------------------------------------------------------------- the -Status audit +def _status_against(home: Path) -> str: + """Run ``-Status`` with ``USERPROFILE`` pointed at a synthetic home. + + ``-Status`` sits above the CLAUDECODE refusal and above every write path, so this reads the fixture + and touches nothing machine-global. That constraint is the whole reason #1024 was scored a 3: a + session must NOT execute this installer for real, because it rewrites user-scope wiring for every + session on the box. Redirecting HOME is how the writer's own predicate gets exercised anyway. + """ + if shutil.which("pwsh") is None: + pytest.skip("SKIP (nothing run): pwsh not on PATH") + r = subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-File", str(INSTALLER), "-Status"], + capture_output=True, + text=True, + timeout=120, + env={**os.environ, "USERPROFILE": str(home), "CLAUDECODE": "1"}, + ) + assert r.returncode == 0, f"-Status must never fail:\n{(r.stderr + r.stdout)[:1200]}" + print(r.stdout) + return r.stdout + + +def _fake_home(root: Path, names: list[str], *, wired: set[str] | None = None) -> Path: + """A home holding `names` as directories, each with a settings.json carrying gate wiring.""" + home = root / "home" + home.mkdir() + settings = json.dumps( + { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|PowerShell", + "hooks": [ + { + "type": "command", + "command": 'pwsh -NoProfile -File "~/worktree_gate.ps1"', + } + ], + } + ] + } + } + ) + for name in names: + d = home / name + d.mkdir() + if wired is None or name in wired: + (d / "settings.json").write_text(settings, encoding="utf-8") + return home + + +def test_the_writer_wires_exactly_the_dirs_the_reader_agrees_to_judge(tmp_path: Path) -> None: + """BACKLOG #1024. The installer's config-dir glob was unanchored, so it wired any directory whose + name merely BEGINS with ``.claude-account-`` -- measured 2026-08-04, ``~/.claude-account-2.lock`` + is a directory and got three gate matchers written into it on every run. + + The reader in ``test_gate_installed_parity`` was anchored by #199 and deliberately left the writer + alone. This asserts the two predicates now agree, by running the REAL writer over the same name + corpus the reader's own negative control uses and importing the reader's pattern rather than + restating it -- a restatement is a third predicate, and this defect was two predicates disagreeing. + """ + from test_gate_installed_parity import _ACCOUNT_DIR_NAME + + names = [ + ".claude", + ".claude-account-1", + ".claude-account-42", + ".claude-account-2.lock", # the measured artifact + ".claude-account-2.bak", # the next artifact shape a `.lock` blocklist would miss + ".claude-account-2-old", + ".claude-account-alpha", # KNOWN COST: a named account would be wrongly excluded + ".claude-account-2b", # KNOWN COST: a suffixed account would be wrongly excluded + ] + out = _status_against(_fake_home(tmp_path, names)) + + wired_lines = [ln for ln in out.splitlines() if ln.startswith("wiring :")] + wired = {Path(ln.split(":", 1)[1].strip()).parent.name for ln in wired_lines} + reader = {n for n in names if n == ".claude" or _ACCOUNT_DIR_NAME.fullmatch(n)} + assert wired == reader, ( + f"writer and reader disagree about which config dirs are launchers.\n" + f" writer wires : {sorted(wired)}\n" + f" reader judges: {sorted(reader)}" + ) + # Guard the guard: an agreement over an empty set would prove nothing, and neither would one where + # the corpus contained no rejectable name. + assert reader, "the corpus contains no accepted name -- this comparison would be vacuous" + assert set(names) - reader, "the corpus contains no rejected name -- ditto" + assert ".claude-account-2.lock" not in wired + + +def test_status_reports_orphan_wiring_in_a_dir_the_installer_no_longer_writes( + tmp_path: Path, +) -> None: + """BACKLOG #1024, the half that anchoring alone does not fix. + + Before this, ``-Status`` scanned exactly the set the installer WRITES, so it could only ever confirm + the installer's own output: both halves used one unanchored glob, so the writer manufactured wiring + in ``.claude-account-2.lock`` and the reader read it back as evidence the wiring was right. Anchoring + the writer stops the wiring being re-created, and it also puts the existing artifact permanently out + of ``-Uninstall``'s reach -- so the audit must NAME it rather than let it fall silent. + + The independent population is the point: enumerated from ``~/.claude*`` and judged by name + afterwards, rather than selected by the predicate whose correctness it exists to check. + """ + home = _fake_home(tmp_path, [".claude", ".claude-account-1", ".claude-account-2.lock"]) + out = _status_against(home) + + assert ".claude-account-2.lock" in out, "the audit does not mention the artifact at all" + assert "ORPHAN GATE WIRING in .claude-account-2.lock" in out + assert "will neither refresh nor remove it" in out + # The remedy must be a command that actually reaches it, since -Uninstall no longer does. + assert '-Uninstall -ConfigDir "' in out + # PRINT WHAT WAS SCANNED, not just a verdict: the audit's population is stated by name. + found = next(ln for ln in out.splitlines() if "found :" in ln) + assert ".claude-account-2.lock" in found and ".claude-account-1" in found + # And the audit really is wider than the wire set -- otherwise it is the same loop with a new label. + assert "scanned 2 config dir(s)" in out + + +def test_the_audit_stays_quiet_when_no_dir_outside_the_wire_set_carries_wiring( + tmp_path: Path, +) -> None: + """NEGATIVE CONTROL for the report above. A line that appears on every run is one readers skip, and + that is how a real orphan goes unnoticed -- so prove the loud arm is discriminating, not constant. + + The decoy is present as a DIRECTORY here, with no settings.json: right name shape to be excluded, + nothing wired to report. The audit must say so and must not cry wolf. + """ + home = _fake_home( + tmp_path, + [".claude", ".claude-account-1", ".claude-account-2.lock"], + wired={".claude", ".claude-account-1"}, + ) + out = _status_against(home) + assert "ORPHAN GATE WIRING" not in out + assert "every dir found is in the wire set above; nothing is unjudged" in out + assert "scanned 2 config dir(s)" in out + + def test_status_prints_a_sha_beside_each_version() -> None: """`-Status` is the only way to see whether the RUNNING gate matches this checkout, and nothing exercised it. It also shipped a defect worth pinning: `$GateVersion` is bumped by hand, and rules 1a, From 40f10290a72d360bac6b4df3335e4d1dbb05c812 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 11:49:37 -0500 Subject: [PATCH 23/28] test(gate): tie the three copies of the gate-rule text scan together (BACKLOG #1018) The two regexes that read worktree_gate.ps1 as text and extract every tool it branches on are implemented THREE times -- test_install_gate_wiring.py, test_gate_installed_parity.py, and Get-HandledTools in install-gate.ps1 (PowerShell) -- with nothing tying them together. They compute the same quantity, so this is duplication, not resemblance. They do NOT disagree today. The hazard is a one-sided edit, and what makes it worth a test is the FAILURE DIRECTION: an under-matching copy 2 shrinks `required` in test_every_non_optional_rule_is_wired_in_every_config_dir, so it passes having checked less; an under-matching copy 3 prints no UNWIRED line from -Status. Both are false greens, in the files written because a rule once shipped dead while 85 tests stayed green -- and neither is visible from inside the copy that changed. NOT UNIFIED, deliberately and per the item. A shared Python helper cannot absorb the PowerShell copy, so the honest end state is one helper plus a cross-language agreement test; this is that second half, and it stands alone. Each real implementation is driven as it stands over one corpus -- copy 1 through its module constant, copy 2 by its text argument, copy 3 by lifting its function out of the installer with the PowerShell AST and defining it in a fresh session (so the installer, which rewrites machine-global wiring, is never executed). A regex that carved the function body out by text would be a text scan of a text scanner. Nothing here re-implements the regexes: a fourth copy written to test the other three would be the same defect with better manners. Three arms: - The REAL gate. All three must agree, and each must return something -- three implementations that all return the empty set agree perfectly and measure nothing. - Six regex shapes (-in, -notin, spacing, single quotes, a hyphenated name, two branches). Each asserts the EXPECTED set, not just mutual agreement: three copies agreeing on the wrong answer is a state this file would otherwise call healthy. - The ONE known divergence, pinned with its reason. Copy 2 alone drops whole-line # comments, because the real gate quotes rule 4's condition in prose as well as in code. On the real gate that difference is invisible, which is exactly why it needs a corpus that separates it. Pinned, it becomes a stated property; any change to it fails here and has to be argued rather than absorbed. RED FIRST, both directions, exactly the demonstration the item prescribes. Changing the quote pattern to `"([A-Z][a-z]+)"` in copy 3 failed 5 of 8, printing 1 ...tools_the_gate_handles: ['GhostTool', 'RealTool'] 2 ...handled_tools: ['RealTool'] 3 ...Get-HandledTools: [] and the same change in copy 2 produced the mirror image, with [] on line 2 and both names on 1 and 3. Three of the eight stayed green under each perturbation, because that pattern under-matches only camel-case and hyphenated names -- so the test says which construct broke rather than only that something did. --- tests/test_gate_rule_scan_agreement.py | 232 +++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 tests/test_gate_rule_scan_agreement.py diff --git a/tests/test_gate_rule_scan_agreement.py b/tests/test_gate_rule_scan_agreement.py new file mode 100644 index 00000000..3cd32419 --- /dev/null +++ b/tests/test_gate_rule_scan_agreement.py @@ -0,0 +1,232 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""The gate-rule text scan exists THREE times. Nothing tied the three together (BACKLOG #1018). + +Each reads ``worktree_gate.ps1`` as text and extracts every tool name the gate branches on, with the +same pair of regexes: + +1. :func:`test_install_gate_wiring.tools_the_gate_handles` -- Python, raw text. +2. :func:`test_gate_installed_parity.handled_tools` -- Python, over ``_code_lines()`` (whole-line ``#`` + comments dropped). +3. ``Get-HandledTools`` in ``scripts/worktree/install-gate.ps1`` -- PowerShell, raw text, and the one + that decides what ``-Status`` prints. + +They compute the same quantity, so this is duplication rather than resemblance. **This file does not +unify them** -- a shared Python helper could never absorb the PowerShell copy, so the honest end state +is one helper plus a cross-language agreement test, and without that second half the item would read +done while two implementations still floated. This is that second half, standing on its own. + +WHAT IT BUYS, which is not "they might disagree today" -- they do not. It is the FAILURE DIRECTION of a +future one-sided edit: + +* an under-matching copy 2 shrinks ``required`` in ``test_every_non_optional_rule_is_wired_in_every_ + config_dir``, so that test passes having checked less; +* an under-matching copy 3 prints no UNWIRED line from ``-Status``. + +Both are false greens, in the files written *because* a rule once shipped dead while 85 tests stayed +green. Neither is visible from inside the copy that changed. + +HOW THE THREE ARE DRIVEN. Copies 1 and 3 read a PATH and copy 2 takes TEXT, so the corpus is written to +a file and each real implementation is invoked as it stands -- copy 1 through its module constant, copy +3 by lifting its function out of the installer with the PowerShell AST and defining it in a fresh +session. Nothing here re-implements the regexes; a fourth copy written to test the other three would be +the same defect with better manners. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest +import test_gate_installed_parity as parity +import test_install_gate_wiring as wiring + +ROOT = Path(__file__).resolve().parents[1] +GATE = ROOT / "scripts" / "hooks" / "worktree_gate.ps1" +INSTALLER = ROOT / "scripts" / "worktree" / "install-gate.ps1" + +# Lift the PowerShell copy out of the installer WITHOUT running the installer, which writes user-scope +# hook wiring for every session on this box. The AST is used rather than a text slice on purpose: a +# regex that carves a function body out of a script would itself be a text scan of a text scanner, and +# it would break on the next brace someone adds. +_EXTRACT = r""" +param([string]$Installer, [string]$Corpus) +$ErrorActionPreference = 'Stop' +$ast = [System.Management.Automation.Language.Parser]::ParseFile($Installer, [ref]$null, [ref]$null) +$fn = $ast.Find({ + param($n) + $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Get-HandledTools' +}, $true) +if (-not $fn) { throw "Get-HandledTools is not defined in $Installer -- copy 3 moved or was renamed" } +. ([scriptblock]::Create($fn.Extent.Text)) +@(Get-HandledTools $Corpus) | Sort-Object | ConvertTo-Json -AsArray +""" + + +def _powershell_copy(corpus: Path, tmp_path: Path) -> set[str]: + runner = tmp_path / "extract-handled-tools.ps1" + runner.write_text(_EXTRACT, encoding="utf-8") + r = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(runner), + "-Installer", + str(INSTALLER), + "-Corpus", + str(corpus), + ], + capture_output=True, + text=True, + timeout=120, + check=False, + ) + assert r.returncode == 0, f"lifting Get-HandledTools failed:\n{r.stderr}\n{r.stdout}" + return set(json.loads(r.stdout or "[]")) + + +def _all_three( + corpus: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> dict[str, set[str]]: + """Every implementation, run as it stands, over one corpus.""" + text = corpus.read_text(encoding="utf-8") + monkeypatch.setattr(wiring, "GATE", corpus) + return { + "1 test_install_gate_wiring.tools_the_gate_handles": wiring.tools_the_gate_handles(), + "2 test_gate_installed_parity.handled_tools": parity.handled_tools(text), + "3 install-gate.ps1 Get-HandledTools": _powershell_copy(corpus, tmp_path), + } + + +def _report(results: dict[str, set[str]]) -> str: + return "\n".join(f" {name}: {sorted(tools)}" for name, tools in results.items()) + + +pytestmark = pytest.mark.skipif( + shutil.which("pwsh") is None, + reason="copy 3 is PowerShell; without pwsh only two of the three can be compared, and a " + "two-way agreement reported as a three-way one is exactly the class this file exists for", +) + + +def test_the_three_gate_rule_scanners_agree_on_the_real_gate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The corpus that matters: the actual gate all three are pointed at in production. + + Non-emptiness is asserted first, and it is not decoration -- three implementations that all return + the empty set agree perfectly and measure nothing, which is the shape of a regex that stopped + matching after a syntax change in the gate. + """ + results = _all_three(GATE, tmp_path, monkeypatch) + print(f"corpus: {GATE}\n{_report(results)}") + + for name, tools in results.items(): + assert tools, f"{name} extracted NOTHING from the real gate -- agreement would be vacuous" + + distinct = {frozenset(t) for t in results.values()} + assert len(distinct) == 1, ( + f"the three gate-rule scanners disagree about the REAL gate:\n{_report(results)}\n" + f"Whichever is under-matching produces a FALSE GREEN, not a red: a smaller set shrinks " + f"`required` in test_gate_installed_parity, and it removes UNWIRED lines from " + f"install-gate.ps1 -Status. Fix the copy that moved; do not adjust this test to accept it." + ) + + +@pytest.mark.parametrize( + ("label", "body", "expected"), + [ + ( + "-in with two names", + '\nif ($tool -in @("Bash", "PowerShell")) { }\n', + {"Bash", "PowerShell"}, + ), + ( + "-notin, the negated form", + '\nif ($tool -notin @("Write", "Edit")) { }\n', + {"Write", "Edit"}, + ), + ( + "extra whitespace either side of the operator", + '\nif ($tool -in @( "Task" )) { }\n', + {"Task"}, + ), + ( + "a single-quoted name, which the QUOTED regex does not admit", + "\nif ($tool -in @('Agent')) { }\n", + set(), + ), + ( + "a name carrying a hyphen", + '\nif ($tool -in @("Notebook-Edit")) { }\n', + {"Notebook-Edit"}, + ), + ( + "two branches on separate lines", + '\nif ($tool -in @("A")) { }\nif ($tool -notin @("B")) { }\n', + {"A", "B"}, + ), + ], +) +def test_the_three_agree_on_each_regex_shape( + label: str, + body: str, + expected: set[str], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Shape by shape, so a disagreement names the construct that caused it. + + ``expected`` is asserted too, not just mutual agreement: three copies that agree on the WRONG answer + is a state this file would otherwise call healthy. + """ + corpus = tmp_path / "corpus.ps1" + corpus.write_text(body, encoding="utf-8") + results = _all_three(corpus, tmp_path, monkeypatch) + print(f"{label}\n{_report(results)}") + + for name, tools in results.items(): + assert tools == expected, ( + f"{label}: {name} read {sorted(tools)}, expected {sorted(expected)}" + ) + + +def test_the_one_known_divergence_is_the_comment_filter_and_is_pinned_here( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The three are NOT identical, and pretending otherwise is how the difference goes unexamined. + + Copy 2 alone drops whole-line ``#`` comments before scanning, deliberately: the real gate quotes + rule 4's condition verbatim in a comment as well as in the rule, so a raw scan credits the gate with + a rule on the strength of PROSE. Copies 1 and 3 have no such filter. + + On the real gate the difference is invisible -- the commented condition names a tool the code + branches on anyway -- which is exactly why it needs a corpus that separates them. Pinning it here + means the divergence is a stated property with a reason attached, and any change to it (a filter + added to copies 1 or 3, or removed from copy 2) fails this test and has to be argued rather than + absorbed. + """ + corpus = tmp_path / "corpus.ps1" + corpus.write_text( + '\n# A dead branch quoted in prose: if ($tool -in @("GhostTool")) { }\n' + 'if ($tool -in @("RealTool")) { }\n', + encoding="utf-8", + ) + results = _all_three(corpus, tmp_path, monkeypatch) + print(_report(results)) + + assert results["1 test_install_gate_wiring.tools_the_gate_handles"] == { + "GhostTool", + "RealTool", + } + assert results["3 install-gate.ps1 Get-HandledTools"] == {"GhostTool", "RealTool"} + assert results["2 test_gate_installed_parity.handled_tools"] == {"RealTool"}, ( + "copy 2's comment filter is the ONE documented difference between the three scanners. If it is " + "gone, the credit-a-rule-from-prose failure it was added for is back; if copies 1 or 3 grew one " + "too, delete this pin and say so." + ) From 12595cc6de2397a8f93d50cbc1319916ace0b660 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 11:53:13 -0500 Subject: [PATCH 24/28] fix(worktree): the new -Status audit must not report a clean verdict over an empty population (BACKLOG #1024) Found by reading back my own output. With no ~/.claude* dir carrying a settings.json the audit fell through to "every dir found is in the wire set above; nothing is unjudged" -- reassurance derived from nothing having been measured, which is the exact class the audit was added to remove. Measured, not reasoned: reverting the guard and re-running prints that line under `audit : 0 ~/.claude* dir(s)`. "I found nothing" and "I found things and they are all fine" print identically the moment a scan reports only its verdict. presence.ps1 already draws this distinction between an empty roster and an unavailable one; the audit now draws it too, saying NOTHING EXAMINED and naming the home it looked under. --- scripts/worktree/install-gate.ps1 | 9 ++++++++- tests/test_install_gate_wiring.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/scripts/worktree/install-gate.ps1 b/scripts/worktree/install-gate.ps1 index 4b4940b1..50a11287 100644 --- a/scripts/worktree/install-gate.ps1 +++ b/scripts/worktree/install-gate.ps1 @@ -353,7 +353,14 @@ if ($Status) { Write-Host " not judged: $($d.Name) ($why) -- carries no gate wiring" } } - if ($notJudged.Count -eq 0) { + # "I found nothing" and "I found things and they are all fine" are different sentences, and only the + # second is reassurance. Collapsing them is the failure this whole audit exists to remove, so an + # empty population says NOTHING WAS EXAMINED rather than borrowing the clean verdict below it. + if ($seen.Count -eq 0) { + Write-Host " NOTHING EXAMINED -- no ~/.claude* dir under $HomeDir carries a settings.json," -ForegroundColor Yellow + Write-Host " so this audit concluded nothing. That is not the same as 'no orphans'." + } + elseif ($notJudged.Count -eq 0) { Write-Host " every dir found is in the wire set above; nothing is unjudged" } elseif ($orphans.Count -eq 0) { diff --git a/tests/test_install_gate_wiring.py b/tests/test_install_gate_wiring.py index 7329e085..971011a1 100644 --- a/tests/test_install_gate_wiring.py +++ b/tests/test_install_gate_wiring.py @@ -285,6 +285,23 @@ def test_the_audit_stays_quiet_when_no_dir_outside_the_wire_set_carries_wiring( assert "scanned 2 config dir(s)" in out +def test_an_empty_audit_population_says_nothing_was_examined(tmp_path: Path) -> None: + """The clean verdict must not be reachable when nothing was measured. + + "I found nothing" and "I found things and they are all fine" print identically the moment a scan + reports only its verdict, and the second is the only one that is reassurance. This is the same + distinction presence.ps1 draws between an empty roster and an unavailable one, and the same one + #1000 is about, applied to this audit's own output. + """ + home = tmp_path / "home" + home.mkdir() + out = _status_against(home) + assert "NOTHING EXAMINED" in out + assert "That is not the same as 'no orphans'." in out + assert "every dir found is in the wire set above" not in out + assert "no unjudged dir carries gate wiring" not in out + + def test_status_prints_a_sha_beside_each_version() -> None: """`-Status` is the only way to see whether the RUNNING gate matches this checkout, and nothing exercised it. It also shipped a defect worth pinning: `$GateVersion` is bumped by hand, and rules 1a, From 09f14efde7930f9a4d6383effdf27ee41e9276b6 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 12:02:11 -0500 Subject: [PATCH 25/28] fix(sandbox): install the PHI filter chain on the worker child (BACKLOG #1054) The ADR 0087 sandbox child configured logging with a bare `logging.basicConfig`, whose handler carries no filters. Redaction here is a property of the HANDLER, not of the logger or the call site, so the child's records reached the engine's inherited stderr with neither PHI redaction nor CR/LF neutralization: the three filters `_install_phi_filters` puts on the engine's own handlers were simply absent in that process. `logging_setup` grows a public `configure_stderr_logging()` -- the same chain (RedactionFilter -> CredentialQueryScrubFilter -> ControlCharScrubFilter) and the same text formatter as `configure_logging`, bound to stderr because the caller's stdout is a binary channel. The worker calls it in place of `basicConfig`. Nothing is leaking today: `[sandbox].mode` defaults to "off" and there are zero deployments. On a first deployment that opted into mode="subprocess", a WARNING+ record emitted by admin-authored Router/Handler code would have carried message-derived content onto stream 1's own sink unredacted and un-neutralized. Measured, not reasoned. The new subprocess test imports the real worker module in a child process and asserts on its actual stderr; against the pre-fix code it fails with the defect visible in the assertion output -- the child emitted the synthetic `PID|1||100^^^H^MR||DOE^JANE^Q||19800101|F` verbatim, and the raw CR/LF put the injected text at column 0 on its own physical line. docs/PHI.md changes in this same commit because the fix closes the weakness the doc disclosed. `test_the_sandbox_worker_stderr_writer_is_disclosed` couples the two by construction: once the child is filtered it demands the "outside the filter chain" exclusion be dropped. That test is re-pinned both ways, with an added assertion that the child uses one mechanism or the other, so removing both cannot pass green. The sink-module inventory drops the worker for the same reason -- it no longer builds a sink, it asks logging_setup for one. --- docs/PHI.md | 15 ++++-- messagefoundry/logging_setup.py | 34 +++++++++++++ messagefoundry/pipeline/_sandbox_worker.py | 18 +++++-- tests/test_logging.py | 59 ++++++++++++++++++++++ tests/test_phi_logging_inventory.py | 39 +++++++++----- tests/test_sandbox_worker_logging.py | 55 ++++++++++++++++++++ 6 files changed, 198 insertions(+), 22 deletions(-) create mode 100644 tests/test_sandbox_worker_logging.py diff --git a/docs/PHI.md b/docs/PHI.md index a572d107..7e5f9edb 100644 --- a/docs/PHI.md +++ b/docs/PHI.md @@ -844,9 +844,10 @@ heuristic reminder and never blocks the gate. The existing controls — never lo INFO+, the CR/LF log-injection filter, and silencing python-hl7's PHI-prone loggers — remain in [logging_setup.py](../messagefoundry/logging_setup.py). -**Global log redaction + prod-DEBUG guard `[BUILT]` (Gate #1).** **Three** handler filters run, **on every record emitted in the engine process**, in this +**Global log redaction + prod-DEBUG guard `[BUILT]` (Gate #1).** **Three** handler filters run, **on every record emitted by the engine process and by the ADR 0087 sandbox worker child**, in this order, on **every** emitted record and on **every** handler — stdout *and* the off-box forwarder — -installed by `configure_logging`'s `_install_phi_filters` +installed by `_install_phi_filters`, reached through `configure_logging` in the engine and +`configure_stderr_logging` in the child ([logging_setup.py](../messagefoundry/logging_setup.py)): 1. **`RedactionFilter`** — `redact()`-scrubs both the rendered **message** and the formatted **exception @@ -983,10 +984,18 @@ WebSocket are **live telemetry, not log records** — neither retains a per-even logging (`relay_log` — direction/leg/control id/type/size/outcome/ack code + a sanitized 500-char detail, never a body; and `relay_capture`, whose `raw` column holds the **full message** and is written only when `--capture-bodies` is passed). Its process logging is a bare `logging.basicConfig` to stderr with **none** -of the filters above. The **opt-in ADR 0087 sandbox worker** (`[sandbox].mode = "subprocess"`, default `"off"`) has the same shape *inside* the product: the child is spawned with `stderr=None`, i.e. it **inherits the engine's stderr** — stream 1's own sink — and configures itself with a bare `logging.basicConfig(stream=sys.stderr, level=WARNING)`, so a `WARNING`+ record emitted there by admin-authored Router/Handler code (or a library it pulls) lands in `service.err.log` **outside the filter chain**. The compensating controls are the sandbox's own `[egress]` / forbidden-import gates and the never-log-bodies rule. It is **out of scope for this section** and documented with the relay — but treat a +of the filters above. It is **out of scope for this section** — but treat a `--capture-bodies` capture store as a PHI-at-rest location on the terms of [§2](#2-where-phi-lives--data-at-rest-inventory). +The **opt-in ADR 0087 sandbox worker** (`[sandbox].mode = "subprocess"`, default `"off"`) is **not** an +exclusion. The child is spawned with `stderr=None`, so it **inherits the engine's stderr** — stream 1's +own sink — and it installs the three filters above on that stream itself, via `configure_stderr_logging` +(BACKLOG #1054). A `WARNING`+ record emitted in the child by admin-authored Router/Handler code, or by a +library it pulls, is therefore redacted and CR/LF-scrubbed on the same terms as an engine record. +Redaction is a property of the **handler**, so this is a second installation of the chain rather than +something the child inherits along with the file descriptor. + --- ## 8. Retention & purge diff --git a/messagefoundry/logging_setup.py b/messagefoundry/logging_setup.py index 2a3d78ef..6f4bf20b 100644 --- a/messagefoundry/logging_setup.py +++ b/messagefoundry/logging_setup.py @@ -35,6 +35,7 @@ __all__ = [ "configure_logging", + "configure_stderr_logging", "set_runtime_level", "current_log_level", "silence_phi_prone_dependency_loggers", @@ -464,6 +465,39 @@ def configure_logging( return forwarder_installed +def configure_stderr_logging(level: int = logging.WARNING) -> logging.Handler: + """Install a **stderr-only** root handler carrying the same PHI-redaction + control-char-scrub + filter chain :func:`configure_logging` puts on stdout, and return it. + + For a MessageFoundry child process whose **stdout is a binary channel**: today the ADR 0087 sandbox + worker, whose stdout carries the MFW2 IPC frames, so a stray log byte written there would corrupt a + frame. The obvious way to express that — ``logging.basicConfig(stream=sys.stderr)`` — gets the + stream right and the *filters* wrong. It installs a handler with **no filters at all**, and + redaction here is a property of the **handler**, not of the logger or the call site (see + :func:`_install_phi_filters`), so a child that builds its own handler builds an unfiltered one + unless it asks for the chain: its records would reach the inherited stderr with neither PHI + redaction nor CR/LF neutralization (BACKLOG #1054). Every process that logs installs the chain, or + it does not have it. + + The text formatter is the shared one, so a child line is byte-compatible with the parent's and + :class:`ControlCharScrubFilter`'s "no line may impersonate the record prefix" guarantee is stated + against the same prefix on both streams. + + Replaces any handlers already on the root logger, exactly as :func:`configure_logging` does, so it + is idempotent and safe to call from a test. + """ + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(_make_formatter("text")) + _install_phi_filters(handler) + + root = logging.getLogger() + for existing in list(root.handlers): + root.removeHandler(existing) + root.addHandler(handler) + root.setLevel(level) + return handler + + def set_runtime_level(level: str) -> str: """Change the live root + uvicorn log level at runtime (BACKLOG #171, ADR 0130), WITHOUT rebuilding handlers — the surgical counterpart of :func:`configure_logging`, which owns the stream/off-box diff --git a/messagefoundry/pipeline/_sandbox_worker.py b/messagefoundry/pipeline/_sandbox_worker.py index 93c8668a..efa12925 100644 --- a/messagefoundry/pipeline/_sandbox_worker.py +++ b/messagefoundry/pipeline/_sandbox_worker.py @@ -24,9 +24,12 @@ The reply echoes the request's ``id``, ``phase`` and ``name`` so the parent can prove the frame answers the call it made. -stdout is the binary IPC channel — **nothing else may write to it**. Logging and any diagnostics go -to stderr (inherited by the engine). The engine parent enforces the wall-clock cap and kills a -runaway child, so this process never needs its own watchdog. +stdout is the binary IPC channel — **nothing else may write to it**. Logging and any diagnostics go to +stderr (inherited by the engine) through the **same PHI-redaction + control-char-scrub filter chain the +engine installs on its own handlers** (:func:`~messagefoundry.logging_setup.configure_stderr_logging`), +so a child log line carrying message-derived content is redacted and CR/LF-neutralized here rather than +arriving raw on the inherited stream. The engine parent enforces the wall-clock cap and kills a runaway +child, so this process never needs its own watchdog. """ from __future__ import annotations @@ -37,8 +40,13 @@ from dataclasses import replace from typing import Any -# stdout is the IPC channel; keep the root logger on stderr so a stray log line can't corrupt a frame. -logging.basicConfig(stream=sys.stderr, level=logging.WARNING) +from messagefoundry.logging_setup import configure_stderr_logging + +# stdout is the IPC channel, so the root logger goes to stderr — a stray log line there cannot corrupt +# a frame. It is `configure_stderr_logging` rather than a bare `basicConfig` because redaction is a +# property of the HANDLER: basicConfig's handler carries no filters, so this child's records would +# reach the engine's stderr with neither PHI redaction nor CR/LF scrubbing (BACKLOG #1054). +configure_stderr_logging() log = logging.getLogger("messagefoundry.sandbox.worker") diff --git a/tests/test_logging.py b/tests/test_logging.py index 3065cd1f..0608f1ca 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -25,8 +25,13 @@ SyslogForward, _make_formatter, configure_logging, + configure_stderr_logging, ) +#: Synthetic HL7 (never real PHI) embedded in a log record so a redaction assertion has something to +#: find. HL7-shaped, so ``redact`` rewrites the span rather than passing it through. +SYNTHETIC_PHI = "PID|1||100^^^H^MR||DOE^JANE^Q||19800101|F" + @pytest.fixture(autouse=True) def _restore_root_logger() -> Iterator[None]: @@ -722,6 +727,60 @@ def test_configure_logging_tls_forwarder_roundtrip(tmp_path: Any) -> None: server.close() +# --- configure_stderr_logging (BACKLOG #1054) --------------------------------- +# The child-process variant: same filter chain as configure_logging, bound to stderr because the +# caller's stdout is a binary IPC channel. A bare basicConfig gets the stream right and the filters +# wrong, which is the defect these cover. + + +def test_configure_stderr_logging_installs_the_filter_chain() -> None: + handler = configure_stderr_logging() + root = logging.getLogger() + assert root.handlers == [handler] # replaces, never stacks (same contract as configure_logging) + assert root.level == logging.WARNING + assert isinstance(handler, logging.StreamHandler) + # Bound to stderr: a child whose stdout carries binary frames must never get a stdout handler. + assert handler.stream is sys.stderr + assert [type(f) for f in handler.filters] == [ + RedactionFilter, + CredentialQueryScrubFilter, + ControlCharScrubFilter, + ] + + +def test_configure_stderr_logging_redacts_phi_and_scrubs_crlf( + capsys: pytest.CaptureFixture[str], +) -> None: + configure_stderr_logging() + logging.getLogger("mefor.child").warning( + "request failed on %s", SYNTHETIC_PHI + "\r\nWARNING mefor.child: forged-record" + ) + err = capsys.readouterr().err + + assert "[redacted]" in err # the HL7 span was rewritten... + for token in ("DOE", "JANE", "19800101", "100^^^H^MR"): + assert token not in err + # ...and the CR/LF was escaped rather than emitted raw, so the injected text cannot start its own + # physical line and impersonate a record. One record on the wire is one line on the stream. + assert "\\r\\n" in err + assert "forged-record" in err # kept and diagnosable, just not at column 0 + assert len([line for line in err.splitlines() if line.strip()]) == 1 + + +def test_configure_stderr_logging_redacts_an_exception_traceback( + capsys: pytest.CaptureFixture[str], +) -> None: + # The realistic child vector: a raise that quoted the message body, rendered by log.exception. + configure_stderr_logging() + try: + raise ValueError(SYNTHETIC_PHI) + except ValueError: + logging.getLogger("mefor.child").exception("dispatch failed") + err = capsys.readouterr().err + assert "ValueError" in err # the exception TYPE survives — the log stays diagnosable + assert "DOE" not in err and "JANE" not in err + + # --- ADR 0080: SNTP probe (query_sntp_offset) --------------------------------- diff --git a/tests/test_phi_logging_inventory.py b/tests/test_phi_logging_inventory.py index b6eb3b69..30ddbd8b 100644 --- a/tests/test_phi_logging_inventory.py +++ b/tests/test_phi_logging_inventory.py @@ -557,10 +557,9 @@ def test_every_diagnostics_field_is_named_in_the_inventory() -> None: "messagefoundry/tray/__main__.py": ( "the tray's RotatingFileHandler — named in 'Not in this inventory, and why'" ), - "messagefoundry/pipeline/_sandbox_worker.py": ( - "the ADR 0087 sandbox child's basicConfig onto the engine's INHERITED stderr — disclosed as " - "the one in-product writer outside the filter chain" - ), + # The ADR 0087 sandbox child is deliberately ABSENT (BACKLOG #1054): it no longer constructs a sink + # of its own. It calls logging_setup's `configure_stderr_logging`, so the handler — and the filter + # chain on it — is built by the module already listed above, and stream 2 covers it. # 16.2.3's other half: a module that WRITES log CONTENT to an operator-chosen destination is a # log sink even though it constructs no logging handler. The support bundle copies a 500-line # app-log tail out of the ACL'd directory into a zip whose whole purpose is to be handed off. @@ -671,12 +670,17 @@ def test_the_tray_file_sink_is_scoped_out_by_name() -> None: ) -def test_the_sandbox_worker_stderr_writer_is_disclosed() -> None: - """The filter-coverage claim is true only of the ENGINE process. +def test_the_sandbox_worker_stderr_writer_is_filtered_not_disclosed() -> None: + """§7's filter-coverage claim must match how the ADR 0087 child actually configures logging. + + The child inherits the engine's stderr (``stderr=None``) either way, so what decides the doc is + whether it builds its own **unfiltered** handler. It used to: a bare ``basicConfig``, whose handler + carries no filters, put WARNING+ records from admin-authored Handler code onto stream 1's own sink + outside the chain, and §7 disclosed that. It now calls ``configure_stderr_logging``, which installs + the same three filters (BACKLOG #1054), so the disclosure must be gone instead. - Under ``[sandbox].mode = "subprocess"`` the ADR 0087 child inherits the engine's stderr - (``stderr=None``) and configures a bare ``basicConfig``, so WARNING+ records from admin-authored - Handler code reach stream 1's own sink unfiltered. Asserted both ways. + Pinned BOTH ways, because the interesting direction is the regression: a future edit that put + ``basicConfig`` back would silently reopen the gap, and this reddens and demands §7 say so again. """ from messagefoundry.config.settings import ServiceSettings @@ -684,10 +688,11 @@ def test_the_sandbox_worker_stderr_writer_is_disclosed() -> None: encoding="utf-8" ) sandbox = (_ROOT / "messagefoundry" / "pipeline" / "sandbox.py").read_text(encoding="utf-8") - inherits = "logging.basicConfig" in worker and "stderr=None" in sandbox + assert "stderr=None" in sandbox, "the child no longer inherits the engine's stderr; revisit §7" + unfiltered = "logging.basicConfig" in worker text = _doc_text() disclosed = "outside the filter chain" in text - if inherits: + if unfiltered: assert disclosed, ( "the sandbox worker child writes to the engine's INHERITED stderr through a bare " "basicConfig, so §7's 'three filters on every record' claim is not true of it. Say so." @@ -696,10 +701,16 @@ def test_the_sandbox_worker_stderr_writer_is_disclosed() -> None: "the filter-coverage sentence must be scoped to the engine process" ) else: - assert not disclosed, "the sandbox child no longer inherits stderr; drop the disclosure" + assert not disclosed, ( + "the sandbox child installs the filter chain itself; §7 must not still disclose it as a " + "writer outside the chain — an exclusion that no longer exists reads as an open weakness" + ) + assert "configure_stderr_logging" in worker, ( + "the child neither uses basicConfig nor configure_stderr_logging — it may have no filter " + "chain at all. Establish which, and say so in §7." + ) assert ServiceSettings().sandbox.mode == "off", ( - "[sandbox].mode no longer defaults off; the disclosure describes an OPT-IN posture — " - "revisit §7 in the same change." + "[sandbox].mode no longer defaults off; §7 describes an OPT-IN posture — revisit it here." ) diff --git a/tests/test_sandbox_worker_logging.py b/tests/test_sandbox_worker_logging.py new file mode 100644 index 00000000..41281c5c --- /dev/null +++ b/tests/test_sandbox_worker_logging.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""BACKLOG #1054: the ADR 0087 sandbox worker CHILD logs through the engine's PHI-redaction + +control-char-scrub filter chain. + +``tests/test_logging.py`` covers :func:`~messagefoundry.logging_setup.configure_stderr_logging` as a +function. This file covers the thing that actually protects a deployment: that the **worker module's +own module-scope wiring** calls it, measured in a real child process rather than by reading the +source. The module reconfigures the root logger on import, so it cannot be imported in-process +without wrecking logging for the rest of the suite — hence the subprocess. +""" + +from __future__ import annotations + +import subprocess +import sys + +#: Synthetic HL7 (never real PHI). HL7-shaped so ``redact`` rewrites the span. +SYNTHETIC_PHI = "PID|1||100^^^H^MR||DOE^JANE^Q||19800101|F" + +# Importing the worker module is the thing under test: its module scope installs the filtered stderr +# handler. The CR/LF is built here rather than passed through argv, where a Windows command line would +# make its survival a property of the shell instead of a property of the code. +_CHILD = ( + "import sys\n" + "import messagefoundry.pipeline._sandbox_worker as worker\n" + "worker.log.warning(\n" + " 'request failed on %s',\n" + " sys.argv[1] + chr(13) + chr(10) + 'WARNING forged-record',\n" + ")\n" +) + + +def test_sandbox_worker_child_logs_redacted_and_scrubbed_to_stderr() -> None: + proc = subprocess.run( # noqa: S603 - fixed argv, this interpreter + [sys.executable, "-c", _CHILD, SYNTHETIC_PHI], + capture_output=True, + text=True, + timeout=120, + check=True, + ) + err = proc.stderr + + # stdout is the MFW2 IPC channel — not one log byte may land there, or a frame is corrupt. + assert proc.stdout == "" + + assert "[redacted]" in err # the HL7 span was rewritten by RedactionFilter... + for token in ("DOE", "JANE", "19800101", "100^^^H^MR"): + assert token not in err, f"unredacted {token!r} reached the child's stderr" + + # ...and ControlCharScrubFilter escaped the CR/LF, so the appended text cannot begin its own + # physical line and impersonate a record prefix on the stream the engine inherits. + assert "\\r\\n" in err + assert "forged-record" in err # kept and diagnosable, just not at column 0 + assert len([line for line in err.splitlines() if line.strip()]) == 1 From 2a118d30bceb78f9e924c5a45744dcd8f0b045a1 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 12:02:36 -0500 Subject: [PATCH 26/28] fix(logging): redact a traceback escaping a non-main thread (BACKLOG #1055) `sys.excepthook` covers the main thread only. The interpreter routes an exception that escapes a thread's run() to `threading.excepthook` and nowhere else, and that was still the stdlib default, which prints a raw traceback straight to stderr -- past the handler filter chain, since it never builds a LogRecord at all. `last_resort` grows `install_thread_excepthook()`, routing through `safe_exc` to the filtered log and naming the thread, and serve() installs it beside the existing `install_excepthook()`. SystemExit is ignored, matching the stdlib default: a thread calling sys.exit() is a clean exit, not an error to report. The engine thread that motivates it is the sandbox session's raw stdout reader, whose except clause catches only OSError by design, so anything else escapes run() and the frame bytes it was mid-read on are message-derived. Nothing is leaking today -- zero deployments. On a first deployment an unexpected non-OSError there would have put an unredacted traceback into the NSSM-captured stderr. Measured with a live positive control reproducing that shape, run against the pre-fix and post-fix trees. Before: the stdlib hook wrote 838 bytes of traceback ending in the full synthetic `PID|1||100^^^H^MR||DOE^JANE^Q||19800101|F`. After: 93 bytes reading `last-resort: uncaught exception in thread 'mefor-sandbox-reader': ValueError: PID|[redacted]` -- thread name and exception type kept, body gone. The committed test drives a real thread rather than calling the hook directly, and asserts stderr carries no traceback at all. --- messagefoundry/__main__.py | 7 +++- messagefoundry/last_resort.py | 41 ++++++++++++++++-- tests/test_last_resort.py | 79 +++++++++++++++++++++++++++++++++-- 3 files changed, 119 insertions(+), 8 deletions(-) diff --git a/messagefoundry/__main__.py b/messagefoundry/__main__.py index 2a0aaa39..cac6da31 100644 --- a/messagefoundry/__main__.py +++ b/messagefoundry/__main__.py @@ -2794,10 +2794,15 @@ def registry_filter(reg: Registry) -> Registry: # noqa: F811 (local shard-bound from messagefoundry.api.tls_client_cert import client_cert_http_protocol_class run_kwargs["http"] = client_cert_http_protocol_class() - from messagefoundry.last_resort import install_excepthook + from messagefoundry.last_resort import install_excepthook, install_thread_excepthook from messagefoundry.redaction import safe_exc install_excepthook() # last-resort main-thread hook: an uncaught exception logs PHI-redacted (16.5.4) + # The sibling hook for every OTHER thread (BACKLOG #1055). sys.excepthook does not cover them, and + # the engine runs non-asyncio threads whose except clauses are deliberately narrow — the sandbox + # session's raw stdout reader catches only OSError — so anything else would otherwise reach the + # stdlib default and print an unredacted traceback to the NSSM-captured stderr. + install_thread_excepthook() try: uvicorn.run(app, host=settings.api.host, port=settings.api.port, **run_kwargs) except Exception as exc: # last-resort: log an abnormal server exit PHI-redacted, then re-raise diff --git a/messagefoundry/last_resort.py b/messagefoundry/last_resort.py index 595305c0..98819d28 100644 --- a/messagefoundry/last_resort.py +++ b/messagefoundry/last_resort.py @@ -4,10 +4,14 @@ Per-request (the API catch-all 500) and per-lane (the pipeline workers + framed listeners) handlers already exist; this adds the **process** backstop. Any asyncio task/callback exception that nothing -awaited, and any uncaught main-thread exception, is routed through -:func:`~messagefoundry.redaction.safe_exc` to the log — so a genuinely-unhandled error can never escape -as a raw traceback (which could quote a PHI-bearing argument) or die silently. It only fires for -otherwise-unhandled errors; normal flow is untouched. +awaited, any uncaught main-thread exception, and any exception that escapes a **non-main thread's** +``run()``, is routed through :func:`~messagefoundry.redaction.safe_exc` to the log — so a +genuinely-unhandled error can never escape as a raw traceback (which could quote a PHI-bearing +argument) or die silently. It only fires for otherwise-unhandled errors; normal flow is untouched. + +The three hooks are separate stdlib surfaces and installing one does not cover another: the asyncio +loop handler sees only loop tasks/callbacks, ``sys.excepthook`` only the main thread, and +``threading.excepthook`` only the others. """ from __future__ import annotations @@ -15,6 +19,7 @@ import asyncio import logging import sys +import threading from types import TracebackType from typing import Any @@ -53,3 +58,31 @@ def install_excepthook() -> None: """Replace ``sys.excepthook`` so an uncaught main-thread exception is logged PHI-redacted instead of printed as a raw traceback (which could quote a PHI-bearing value) to stderr.""" sys.excepthook = _excepthook + + +def _thread_excepthook(args: threading.ExceptHookArgs) -> None: + """``threading.excepthook``: log an exception that escaped a **non-main thread's** ``run()`` + PHI-redacted, instead of letting the stdlib default print a raw traceback to stderr. + + ``sys.excepthook`` does not cover this — the interpreter routes a thread's escaping exception to + ``threading.excepthook`` and nowhere else — so the redaction guarantee already in force on the main + thread must be installed a second time to reach the others (BACKLOG #1055). + + The concrete engine thread is the sandbox session's raw stdout-reader daemon + (``SandboxSession._reader_loop``), whose ``except`` clause catches only ``OSError`` by design; + anything else escapes ``run()`` and lands here, and the frame bytes it was mid-read on are + message-derived. ``SystemExit`` is ignored exactly as the stdlib default ignores it — a thread + calling ``sys.exit()`` is a clean exit, not an error to report. + """ + if args.exc_value is None or issubclass(args.exc_type, SystemExit): + return + where = args.thread.name if args.thread is not None else "" + _log.critical( + "last-resort: uncaught exception in thread %r: %s", where, safe_exc(args.exc_value) + ) + + +def install_thread_excepthook() -> None: + """Replace ``threading.excepthook`` so an exception escaping a non-main thread is logged + PHI-redacted instead of printed as a raw traceback to stderr.""" + threading.excepthook = _thread_excepthook diff --git a/tests/test_last_resort.py b/tests/test_last_resort.py index 19304738..3829b6ea 100644 --- a/tests/test_last_resort.py +++ b/tests/test_last_resort.py @@ -2,23 +2,28 @@ # Copyright (C) 2026 MessageFoundry Organization and contributors """WP-L3-19: process-level last-resort error handling (ASVS 16.5.4). -Verifies the asyncio loop handler + sys.excepthook route an otherwise-unhandled exception through -``safe_exc`` (PHI-redacted, type-preserving) → the log, and that a framed listener (MLLP) survives a -handler that raises — the error is logged redacted, the connection drops, the server stays up. +Verifies the asyncio loop handler + sys.excepthook + threading.excepthook route an otherwise-unhandled +exception through ``safe_exc`` (PHI-redacted, type-preserving) → the log, and that a framed listener +(MLLP) survives a handler that raises — the error is logged redacted, the connection drops, the server +stays up. """ from __future__ import annotations import asyncio import logging +import threading +from collections.abc import Iterator import pytest from messagefoundry.config.models import ConnectorType, Source from messagefoundry.last_resort import ( _excepthook, + _thread_excepthook, install_excepthook, install_loop_exception_handler, + install_thread_excepthook, ) from messagefoundry.transports.mllp import MLLPSource, frame @@ -76,6 +81,74 @@ def test_install_excepthook_sets_sys_hook() -> None: sys.excepthook = original +# --- threading.excepthook (BACKLOG #1055) ------------------------------------- +# sys.excepthook covers the MAIN thread only; an exception escaping any other thread's run() goes to +# threading.excepthook and nowhere else. The engine's concrete case is the sandbox session's raw +# stdout-reader daemon, whose except clause catches only OSError by design. + + +@pytest.fixture +def _restore_thread_excepthook() -> Iterator[None]: + original = threading.excepthook + try: + yield + finally: + threading.excepthook = original + + +def test_thread_excepthook_redacts_an_exception_escaping_a_real_thread( + caplog: pytest.LogCaptureFixture, + capsys: pytest.CaptureFixture[str], + _restore_thread_excepthook: None, +) -> None: + """The end-to-end shape: a live thread raises, the interpreter dispatches to the hook.""" + install_thread_excepthook() + + def boom() -> None: + raise ValueError(PHI) # a non-OSError escaping run() — what the reader loop does not catch + + with caplog.at_level(logging.CRITICAL): + worker = threading.Thread(target=boom, name="mefor-sandbox-reader") + worker.start() + worker.join(timeout=5) + assert not worker.is_alive() + + logged = " ".join(r.getMessage() for r in caplog.records) + assert "uncaught exception in thread" in logged + assert "mefor-sandbox-reader" in logged # which thread died stays diagnosable + assert "ValueError" in logged # ...and so does the type + assert "DOE" not in logged and "JANE" not in logged # PHI redacted by safe_exc + + # The stdlib default would have printed a raw traceback quoting the exception's argument straight + # to stderr, bypassing the handler filter chain entirely. Nothing reaches stderr now. + captured = capsys.readouterr() + assert "DOE" not in captured.err and "JANE" not in captured.err + assert "Traceback" not in captured.err + + +def test_thread_excepthook_ignores_system_exit(caplog: pytest.LogCaptureFixture) -> None: + # Parity with the stdlib default, which silently ignores SystemExit: a thread calling sys.exit() + # is a clean exit, and reporting it as a CRITICAL last-resort error would be a false alarm. + exc = SystemExit(0) + with caplog.at_level(logging.CRITICAL): + _thread_excepthook( + threading.ExceptHookArgs((SystemExit, exc, None, threading.current_thread())) + ) + assert not caplog.records + + +def test_thread_excepthook_tolerates_a_missing_exc_value(caplog: pytest.LogCaptureFixture) -> None: + # threading.ExceptHookArgs types exc_value as optional; the hook must not raise inside the hook. + with caplog.at_level(logging.CRITICAL): + _thread_excepthook(threading.ExceptHookArgs((ValueError, None, None, None))) + assert not caplog.records + + +def test_install_thread_excepthook_sets_hook(_restore_thread_excepthook: None) -> None: + install_thread_excepthook() + assert threading.excepthook is _thread_excepthook + + async def test_mllp_handler_exception_is_caught_and_redacted( caplog: pytest.LogCaptureFixture, ) -> None: From 6ac3f4c1022186cb855a3a780485f3c98e29196b Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 12:46:07 -0500 Subject: [PATCH 27/28] backlog: flip the 17 banners wave 1 landed across lanes 2-6 The coordinator's integration commit, and the single point where the "a PR that implements BACKLOG #N must update BACKLOG.md" required context is satisfied for the whole train. Lanes 4, 5 and 6 touch messagefoundry/ and none of them may edit the ledger, so as independent pull requests three of the six would have gone red on that context. Banner text is each lane's own, carried verbatim from its report rather than re-written here: the lane that measured the fix is the one that should describe it. Ledger after this commit, re-derived with parse_items rather than carried forward: live 241, open 224, closed-in-live 17, archive 236, namespace 477 conserved. Item 64 stays OPEN by instruction -- only its index role over 62/63/47/34 survives, and discharging that umbrella is an owner call. --- docs/BACKLOG.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 0c212acf..4357f9c6 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -4137,7 +4137,7 @@ Retiring the tree costs the engine nothing operationally: **`tests/test_ech_egre ## 1018. The raw-text gate-rule scan exists in three independent copies with nothing tying them together -> 🔢 **Filed 2026-08-04 — not started.** Value **4/10** · Difficulty **3/10** · _fill-in_. Two regexes that read a gate script as text and extract every tool it dispatches on are implemented three times — twice in Python, once in PowerShell — and no test compares any two of them. They agree today; the defect is a synchronised-edit hazard whose failure direction is a false green in the machinery built to stop rules shipping dead. +> ✅ **Shipped 2026-08-10.** [`tests/test_gate_rule_scan_agreement.py`](../tests/test_gate_rule_scan_agreement.py) drives all **three** extractors over one corpus and fails when they diverge: `test_install_gate_wiring.tools_the_gate_handles` (through its module constant), `test_gate_installed_parity.handled_tools` (by its text argument), and `install-gate.ps1`'s `Get-HandledTools` (lifted with the PowerShell AST, so the installer — which rewrites machine-global wiring — is never executed). **The three are deliberately NOT unified**: a shared Python helper cannot absorb the PowerShell copy, so the agreement test *is* the deliverable. Eight arms: the real gate (each must return something — three implementations all returning the empty set agree perfectly and measure nothing), six regex shapes asserting the **expected** set rather than only mutual agreement, and the ONE real difference — copy 2's whole-line `#` comment filter — **pinned with its reason** instead of hidden. Red-first in both directions, as the item prescribes: `"([A-Z][a-z]+)"` in copy 3 failed 5 of 8 printing `1: ['GhostTool','RealTool'] / 2: ['RealTool'] / 3: []`; the same change in copy 2 gave the mirror image. **Cluster:** Testing / developer tooling. **Priority:** P3. **Verdict:** build (small). **Severity:** no engine impact and none on first deployment; every file involved is developer session-drift tooling. @@ -4167,7 +4167,7 @@ Retiring the tree costs the engine nothing operationally: **`tests/test_ech_egre ## 1024. install-gate.ps1's config-dir glob is over-wide AND it is the WRITER that manufactured the wiring the reader validated against -> 🔢 **Filed 2026-08-04 — not started.** Value **4/10** · Difficulty **3/10** · _fill-in_. The installer discovers config dirs with an **unanchored** glob, so it wires any directory whose name merely begins with `.claude-account-`. It is the **writer** that put gate wiring into `~/.claude-account-2.lock`, which the Python reader then read back as evidence that wiring was correct — both globs wrong the same way, so they agreed. +> ✅ **Shipped 2026-08-10.** Both halves. **The glob is anchored** on `\A\.claude-account-\d+\z` — the .NET spelling of the predicate #199 gave the Python reader (.NET `\Z` also matches before a trailing newline; `\z` is what Python's `\Z` means) — so writer and reader are now the same predicate and `~/.claude-account-2.lock` is no longer wired. **And `-Status` stopped validating its own output:** it now enumerates every `~/.claude*` dir carrying a `settings.json` **independently of that predicate**, prints the population by name, and reports any dir outside the wire set that still carries gate wiring as `ORPHAN GATE WIRING` with the `-Uninstall -ConfigDir ""` command that reaches it. Reported, never fixed: anchoring puts the existing artifact out of `-Uninstall`'s reach, and which dir is a stale artifact versus a live config root is the owner's call. An empty population now says **NOTHING EXAMINED** rather than borrowing the clean verdict. Verified by redirecting `USERPROFILE` at a fixture home — **the installer itself is never run**, which is exactly why the item was scored difficulty 3. The agreement test imports the reader's pattern rather than restating it; a restatement would be a third predicate, and this defect *was* two predicates disagreeing. **Cluster:** Developer tooling / session-drift controls. **Priority:** P3. **Verdict:** build (small). **Severity:** developer-tooling correctness, no product surface and no deployment effect. Extra wiring in a stale directory is **fail-safe**, not fail-open. @@ -4553,7 +4553,7 @@ against the gate **as it will ship**, not as it is. ## 1037. `remove.ps1` cannot be execution-tested, because it hardcodes its repo root -> 🔢 **Filed 2026-08-05 — not started.** Value **4/10** · Difficulty **3/10** · _fill-in_. `scripts/worktree/remove.ps1` derives its repo root from its own script location with no override, so no test can drive it against a synthetic repository. Its sibling `prune-merged.ps1` accepts a root and IS execution-tested; that is the whole difference. +> ✅ **Shipped 2026-08-10.** `remove.ps1` takes `-RepoRoot` — the same override `prune-merged.ps1` already had, for the same reason — so the most destructive script in `scripts/worktree/` is execution-tested rather than review-covered. Behaviour on the default `$PSScriptRoot` path is unchanged. `tests/test_worktree_remove.py` drives the real script as a subprocess against a synthetic repo under `tmp_path`, never against this checkout, and covers **both** halves of the lossless-delete discipline: the branch contained in `origin/main` that is force-deleted after re-verification (with the recovery recipe and full tip printed first), and the branch holding unique commits that is **kept** with its count reported. Also the branch being read from git rather than the directory name, the detached-HEAD refusal, the untracked `.venv` case, `-Force`, a non-existent `-RepoRoot`, and the `$PSScriptRoot` default itself (exercised against a copy inside the fixture). Red first: 9 of 10 failed with *"A parameter cannot be found that matches parameter name 'RepoRoot'"*. Two mutations then showed the assertions discriminate rather than merely observe survival — `branch -d` → `branch -D` destroyed the branch holding unique commits and killed the keep test, and sourcing the branch from `$Name` killed both the branch-source and detached-HEAD tests. Both reverted; 10 passed. **What.** `remove.ps1` computes its repo root from where it lives. A test therefore cannot point it at a fixture repo, and the only way to exercise it is against the real checkout — which no test may do, since the script removes worktrees and can delete branches. @@ -4623,7 +4623,7 @@ against the gate **as it will ship**, not as it is. ## 1042. The `[vault]` key/secret/transit providers build a redirect-following HTTP client, so a diverted 3xx could carry `X-Vault-Token` off-path -> 🔢 **Filed 2026-08-05 — not started.** Value **4/10** · Difficulty **2/10** · _fill-in_. Every shipped HTTP egress routes through a no-redirect urllib opener (`transports/rest.py` `_NO_REDIRECT_OPENER`), except the `[vault]` provider clients, which build a `requests`-based hvac client with no redirect policy. A deploying site on `messagefoundry[vault]` would carry `X-Vault-Token` over a redirect-following client to the operator-set `VAULT_ADDR`. +> ✅ **SHIPPED 2026-08-10 — all three `[vault]` clients now refuse redirects.** Value **4/10** · Difficulty **2/10** · _fill-in_. `allow_redirects=False` at both `hvac.Client` construction points (`store/keyprovider_vault.py` and `config/secretprovider_vault.py` `_build_client`); `store/crypto_transit.py` already reuses the first, so the Transit cipher inherits the policy rather than growing a second construction point. Verified against the library rather than assumed: hvac 2.4.0's `Client.__init__` takes `allow_redirects` (default `True`), stores it on the adapter, and the adapter passes `allow_redirects=self.allow_redirects` to `requests.Session.request` — recorded in the test module's docstring because CI never installs the `[vault]` extra and no test can reach it there. Four tests in `tests/test_vault_client_redirect_policy.py`: three watched RED (each reported the constructed kwargs as `dict_keys(['url', 'token'])`), and the fourth is a live positive control that the recording stand-in reports an absent policy rather than swallowing an unrecognised kwarg. The Transit client is driven end to end through `build_transit_cipher`, so a private client construction there would red rather than pass an identity check. **Cluster:** Egress / secret handling. **Priority:** P3. **Verdict:** build (small). **Severity:** conditional, not an exposure on the shipping config. The vault provider is behind an optional pip extra and off by default; when selected it points at operator-trusted infrastructure. On first deployment, an on-path 3xx (absent TLS integrity) or a spoofed Vault could divert the bearer token, while every default egress refuses redirects. @@ -4635,7 +4635,7 @@ against the gate **as it will ship**, not as it is. ## 1043. The threat-model drift guard's doc-content assertions go inert when the vault doc is absent, so on the public tree they enforce nothing -> 🔢 **Filed 2026-08-05 — not started.** Value **4/10** · Difficulty **3/10** · _fill-in_. `tests/test_threat_model_doc_drift.py` skips every doc-content assertion (heading-enumeration, planted-omission) when `docs/security/THREAT-MODEL.md` is absent, which it is on the public tree (the doc is deny-listed from the OSS mirror). The compensating control's enforced half lives outside the tree it runs in; the code-only assertions (subprocess-site inventory, default-value locks) still fire. +> ✅ **CLOSED 2026-08-10 — the doc-absent skip now ANNOUNCES itself, the checker mechanism is verified on every tree, and a leg that must enforce can fail closed.** Value **4/10** · Difficulty **3/10** · _fill-in_. Three changes and deliberately not a fourth. (a) Absence is announced once per run as a `ThreatModelDocUnenforced` warning naming the path, every category of assertion that stopped enforcing, what still enforces, and the two env vars — it lands in pytest's warnings summary, which prints even under `-q`, and the skip reason points at it. (b) `MEFOR_THREAT_MODEL_DOC` points the module at a copy elsewhere (the vault working tree) and `MEFOR_REQUIRE_THREAT_MODEL_DOC=1` turns absence into a hard FAILURE, so an enforcing leg is fail-closed rather than best-effort. (c) The checker MECHANISM is now verified on every tree against an in-module stand-in. NOT a fourth: no public stand-in for the document's CONTENT — copying the registries into a tracked fixture would be a green proving only that a fixture matches a fixture, one silent skip traded for a vacuous pass. The guard was run in all three postures: doc absent 12 passed / 89 skipped / 1 warning; stand-in present 79 failed / 21 passed, naming the anchors it could not find; require-flag set with the doc absent, a named hard failure. The row-scoped checker was then broken on purpose and the new self-test reported it asleep. **Cluster:** Measurement / doc-drift integrity. **Priority:** P3. **Verdict:** build (small). **Severity:** no product effect. The defect is a green that is not evidence: on the assessed public tree, the identification of resource-demanding and dangerous functionality (which several ASVS 15.1.x verdicts lean on) has zero drift enforcement, and nothing announces the skip. @@ -4647,7 +4647,7 @@ against the gate **as it will ship**, not as it is. ## 1044. There is no request-timeout on HTTP handlers, so the "response within the consumer's timeout" limb has no server-side enforcement -> 🔢 **Filed 2026-08-05 — not started.** Value **3/10** · Difficulty **3/10** · _fill-in_. The only `asyncio.wait_for` in `api/` is the connection-test probe; there is no request-timeout middleware. ASVS 15.1.3's limb "avoid building a response that takes longer than the consumer's timeout" has no server-side enforcement (properly 15.2.2 territory, surfaced during the 15.1.3 re-verification). +> ✅ **SHIPPED 2026-08-10 — a server-side deadline on BUILDING a response, not on sending it.** Value **3/10** · Difficulty **3/10** · _fill-in_. `api/request_timeout.py` adds a pure-ASGI `RequestTimeoutMiddleware` that refuses with 503 when a handler has not begun responding within the deadline. The clock is cancelled at `http.response.start`, so an attachment download or a large log body already streaming is never cut mid-body — which is also the limb ASVS words: the cost bounded is the handler's, not the network's. Registered directly inside `ClientNetworkMiddleware` and outside everything else, so the deadline covers the attachment CSP re-assert, the console's middleware, the body cap, the security-headers middleware and every auth dependency, while a refused address is still rejected before it can occupy a deadline; that order is pinned by a test rather than described in a comment. Default 120s, overridable per app via `app.state.request_timeout_seconds` (`<= 0` disables) — the seam a `[api]` knob would set, deliberately not wired to config here. Watched RED against the unregistered code: the slow route returned 200 after running to completion. Three positive controls ship with it — a fast handler still answers under the same deadline, a disabled deadline lets the slow handler finish, and a non-numeric state value falls back to the default rather than disabling the control. **Cluster:** Availability. **Priority:** P3. **Verdict:** build (small). **Severity:** no exposure on the shipping config (localhost + auth, single worker). On first deployment a slow handler holds a worker for as long as it runs, with nothing bounding the response time from the server side. @@ -4659,7 +4659,7 @@ against the gate **as it will ship**, not as it is. ## 1045. `redact_unauthorized` fails open, so a future PHI route that forgets the call returns every field unmasked -> 🔢 **Filed 2026-08-05 — not started.** Value **4/10** · Difficulty **2/10** · _fill-in_. `api/field_authz.py` `redact_unauthorized` fails open: field masking happens only where the call is made, and coverage is pinned only by an enumerated test (`tests/test_field_authz_enforcement_sites.py`). A new PHI-returning route added without the call, and not added to the test, would return the whole model unmasked. +> ✅ **CLOSED 2026-08-10 — the default now DENIES: a PHI-bearing response model withholds every gated property from JSON until `redact_unauthorized` releases what the caller's permissions unlock.** Value **4/10** · Difficulty **2/10** · _fill-in_. `api/phi_gate.PhiGatedModel` carries the gate and the six mapped models declare their PHI properties on themselves; a route that forgets the call now returns `null` — a functional defect its author sees — instead of the model in the clear. Pinned end-to-end by `tests/test_field_authz_fail_closed.py`, which mounts exactly such a route and was watched red on the item's own failure mode before the fix, each null assertion paired with a released positive control. Three mechanics chosen against measurement, not taste: a NAMED field serializer with a `str | None` return (a model-level wrap serializer collapses the whole serialization schema to `{"type": "object", "additionalProperties": true}`, so the published OpenAPI would have got vaguer than the code — a test now pins the property types); `when_used="json"`, which covers every path by which one of these models reaches a client while leaving the engine's internal python-mode `MessageSummary` → `MessageDetail` composition working; and class creation that REFUSES a gated name the serializer does not cover, the one way this gate could go quietly inert. Policy (which permission unlocks which property) stays in `api/field_authz.py`, pinned against the models in both directions. The enumeration of call sites still keeps the shipped surfaces honest — this is what makes the route nobody has written yet safe. **Cluster:** Defensive coding / field authorization. **Priority:** P3. **Verdict:** build (small). **Severity:** not an exposure today, verified rather than assumed: every documented PHI surface at HEAD is covered (which is why ASVS 15.3.1 still grades pass). A latent defensive-coding weakness, not a live leak. @@ -4671,7 +4671,7 @@ against the gate **as it will ship**, not as it is. ## 1046. The inbound archive move uses a non-atomic exists-check instead of the `O_EXCL` claim used on the delivery path -> 🔢 **Filed 2026-08-05 — not started.** Value **2/10** · Difficulty **2/10** · _fill-in_. `transports/file.py` `_move` relocates via `_unique`, which uses a non-atomic `if not target.exists()` rather than the `O_EXCL` claim (`_claim_unique`) the delivery path uses. A check-then-act TOCTOU window exists on the archive move. +> ✅ **SHIPPED 2026-08-10 — the archive move claims its destination name atomically.** Value **2/10** · Difficulty **2/10** · _fill-in_. `FileSource._move` routes through `_claim_unique` (the same `os.link`/`O_EXCL` claim the delivery path has used since FILE-5) instead of `path.replace(_unique(...))`, and the now-orphaned `_unique` is deleted rather than left as an invitation back onto the racy form. Claim-then-unlink rather than one rename, because renaming a file over its own hard link is a POSIX no-op and the original would survive; a failed unlink leaves the file archived and re-readable, the same duplicate-read outcome the pre-existing failure arm already had. The error-quarantine move inherits the same claim. `_claim_unique`'s cross-filesystem fallback now streams instead of `read_bytes()`: the archive move claims through it too, and an inbound file is only as small as `max_file_bytes`, unset by default. Scope is unchanged from the filing — the default config cannot race this. RED evidence: two threads on a per-round barrier archiving same-named files into one shared `processed_dir` lost **38 of 120** archived messages pre-fix (a standalone probe of the same shape measured 61/61/64/63/62 of 120 across five runs); post-fix 120 of 120, five runs of five. **The more interesting half is a repaired test.** `test_file_source_move_failure_leaves_file_in_place` forced a failure by patching `Path.replace`, but its inbox had no `.processed` dir, so the move also failed for that reason — once `_move` left `Path.replace` behind the injection went inert and the test kept passing on the missing directory alone. It now creates the archive dir and patches the claim, and was proved load-bearing by removing the patch line and watching it red. **Cluster:** Concurrency. **Priority:** P3. **Verdict:** build (small). **Severity:** no integrity consequence on the default config, verified: the canonical raw message is already durable in the store at ingress (ACK-on-receipt), and the default is one poller per source over an engine-owned `processed_dir`. It would only race under a non-default config where two FileSources share one `processed_dir`, worst case a benign archived-copy name collision. @@ -4683,7 +4683,7 @@ against the gate **as it will ship**, not as it is. ## 1047. The apiclient measures URL length before the query string is appended, so a query-bearing GET can exceed the limit unchecked -> 🔢 **Filed 2026-08-05 — not started.** Value **3/10** · Difficulty **2/10** · _fill-in_. `apiclient/client.py` measures only `len(base_url) + len(path)` against `MAX_REQUEST_URL_LEN`, then `_request` hands `params=` to httpx, which appends the query AFTER the check (the `Authorization` header IS bounded). A query-bearing GET can construct a URI over the limit with nothing refusing it. The apiclient is the frontend ASVS 4.2.5 explicitly names. +> ✅ **SHIPPED 2026-08-10 — the bound measures the URL httpx actually sends.** Value **3/10** · Difficulty **2/10** · _fill-in_. `apiclient/client.py` `_request` now builds the request first (httpx's own resolution step) and measures `str(request.url)`, then dispatches the built request through `send` — which is what `request()` does internally, so the auth and follow-redirects client defaults are unchanged. Every console/harness/tray read goes through `_get`, whose filters were appended as a query string after the old `len(base_url) + len(path)` check. Watched RED: a 9046-char URL reached the transport. The new test replaces the transport with a tripwire rather than a stub response, because the claim is that the request never reaches the wire and a stub 200 could not tell that apart; a positive control pins that a short query still goes out. `test_request_maps_non_2xx_to_apierror` moves its stub one call deeper, from `_http.request` to `_http.send`. **Cluster:** Outbound length bounding / DoS. **Priority:** P3. **Verdict:** build (small). **Severity:** the primary attacker-influenced message-derived HTTP family (REST/SOAP/FHIR) IS bounded at construction + send-time; this is the residual under the 4.2.5 `partial`. On first deployment an operator action (tray/harness/monitor) producing a long query could build an over-long URI the receiving component rejects with a persistent error status. @@ -4695,7 +4695,7 @@ against the gate **as it will ship**, not as it is. ## 1048. The OIDC token-exchange outbound request has no send-time length guard -> 🔢 **Filed 2026-08-05 — not started.** Value **2/10** · Difficulty **2/10** · _fill-in_. `messagefoundry/auth` carries zero `enforce_send_time_length_limits` / `MAX_REQUEST_URL_LEN` calls; the token-exchange `urllib.request.Request` at `auth/oidc/flow.py` has no send-time length guard. +> ✅ **SHIPPED 2026-08-10 — the token exchange is measured before it is sent.** Value **2/10** · Difficulty **2/10** · _fill-in_. `auth/oidc/flow.py` `exchange_code` measures the request line and header block immediately before the POST and refuses with `FlowError`. No third copy of the limit: it calls `transports/rest.py`'s `find_outbound_length_violation`, imported lazily so the pure, socket-free `auth.oidc` package takes no module-scope transports import — the containment `store/keyprovider_vault.py` already uses for the same helper. The raise stays `FlowError` so the caller's audited login-failure mapping still applies, and only the violation class and the length are disclosed. Unchanged from the filing: `token_endpoint` is operator-static config, so the 4.2.5 `partial` does not rest on this limb; the bound earns its place by turning an `env()` value that resolved to a blob into a clear refusal rather than a wire-level surprise on the first federated login. Watched RED against a tripwire opener. Two positive controls ship with it — a normal endpoint still reaches the opener, and the shared bound is asserted to be the 8192 the refusal message quotes. **Cluster:** Outbound length bounding. **Priority:** P3. **Verdict:** build (small). **Severity:** the weaker limb of the two 4.2.5 gaps: `token_endpoint` is operator-static config (validated https at load), not attacker-influenced, so the 4.2.5 `partial` does not depend on it. @@ -4707,7 +4707,7 @@ against the gate **as it will ship**, not as it is. ## 1049. `XmlMessage` exposes only string-expression XPath, so a Handler interpolating tainted data has no framework-provided safe path -> 🔢 **Filed 2026-08-05 — not started.** Value **3/10** · Difficulty **3/10** · _fill-in_. `XmlMessage.find` / `get` / `get_all` / `exists` / `set` all take an `expression: str` into the sole sink `self._root.xpath(...)`; there is zero `etree.XPath()` / `XPathEvaluator` / `$`-bound XPath tree-wide. No first-party dynamic XPath from taint ships today, but `XmlMessage` is exported to code-first Handlers (`parsing/__init__`), so a Handler interpolating HL7/request data into an XPath expression would, on first deployment, have an injection vector with no framework-provided safe alternative. +> ✅ **CLOSED 2026-08-10 — `XmlMessage`'s query methods now take `$name` bindings, so a Handler has a framework-provided safe path for message-derived values.** Value **3/10** · Difficulty **3/10** · _fill-in_. `find` / `get` / `get_all` / `exists` / `set` / `set_attribute` accept keyword bindings — `msg.get("//record[@mrn=$mrn]/note/text()", mrn=untrusted)` — and a bound value is compared as a value, never parsed as expression syntax. The expression (and `set`'s value, `set_attribute`'s name+value) are positional-only so a binding may legitimately be named `expression` or `value`; only `str`/`int`/`float`/`bool` bind, because lxml would also accept a node set, which would let a caller feed one expression's result back in as a sub-expression. An unbound or unbindable variable surfaces as `XmlPathError` naming the variable and its TYPE, never its value. The threat is DEMONSTRATED rather than asserted: a test shows `mrn = "nope' or @mrn!='"` makes the interpolated form select both records where the author meant one, and it is the live positive control for the bound form selecting none. Still not a shipped vulnerability — nothing in-tree reaches `.xpath()` with tainted data; this is hardening for the authoring surface. **Cluster:** Injection hardening / defensive API. **Priority:** P3. **Verdict:** build (small). **Severity:** not a shipped vulnerability (nothing in-tree reaches `.xpath()` with tainted data); a hardening item for the code-first authoring surface, unlike SQL where the driver binds values regardless of the author's statement. @@ -4755,7 +4755,7 @@ against the gate **as it will ship**, not as it is. ## 1054. The opt-in subprocess sandbox child logs through an unfiltered root logger, bypassing the redaction + log-injection scrub -> 🔢 **Filed 2026-08-05 — not started.** Value **3/10** · Difficulty **2/10** · _fill-in_. The ADR 0087 subprocess sandbox child calls a bare `basicConfig`, so its log records do not pass through the three PHI/redaction/control-char filters that `_install_phi_filters` attaches unconditionally to the engine's stdout handler and off-box forwarder. +> ✅ **SHIPPED 2026-08-10 -- the sandbox child installs the redaction chain itself, and the doc exclusion it required is gone.** Value **3/10** · Difficulty **2/10** · _fill-in_. `_sandbox_worker.py`'s module-scope `logging.basicConfig` is replaced by a new public `logging_setup.configure_stderr_logging()`, which binds a stderr handler carrying `RedactionFilter` -> `CredentialQueryScrubFilter` -> `ControlCharScrubFilter` -- the same chain and the same text formatter `configure_logging` installs on the engine's stdout handler and off-box forwarder -- and makes it the child's sole root handler. The framing that matters, and the reason a bare `basicConfig` was the defect rather than a style choice: redaction here is a property of the **handler**, not of the logger or the call site, so a process that builds its own handler builds an UNFILTERED one unless it asks for the chain. `basicConfig` got the stream right and the filters wrong. Verified by a subprocess test that imports the real worker module in a child process and asserts on its actual stderr, confirmed RED against the pre-fix code first, where the child emitted a synthetic `PID|1||100^^^H^MR||DOE^JANE^Q||19800101|F` verbatim AND a raw CR/LF pushed injected text to column 0 as its own physical line. `docs/PHI.md` §7 changed in the same commit because `test_the_sandbox_worker_stderr_writer_is_disclosed` couples the two by construction and demands the 'outside the filter chain' exclusion be dropped once the child is filtered; that test is re-pinned both ways with an added assertion that the child uses one mechanism or the other, so deleting both can no longer pass green. The §7 sink inventory drops the worker for the same reason -- it no longer constructs a sink, it asks `logging_setup` for one. Original filing follows. **Cluster:** Logging / PHI redaction. **Priority:** P3. **Verdict:** build (small). **Severity:** off by default (`[sandbox].mode="off"`), so the default posture's redaction/scrub coverage (ASVS 16.4.1 / 16.2.5) is intact. On a deploying site that opts into `mode="subprocess"`, a child log line carrying message-derived content would reach the inherited stderr without redaction or CR/LF neutralization. @@ -4767,7 +4767,7 @@ against the gate **as it will ship**, not as it is. ## 1055. `threading.excepthook` is unreplaced on the raw sandbox-reader engine thread, so a non-`OSError` traceback there reaches the stdlib hook unredacted -> 🔢 **Filed 2026-08-05 — not started.** Value **2/10** · Difficulty **2/10** · _fill-in_. The engine replaces the asyncio loop exception handler (the last-resort handler for the event loop) but does NOT replace `threading.excepthook`, so a non-`OSError` exception in the raw sandbox-reader daemon thread reaches the stdlib default hook, which prints an unredacted traceback to stderr. +> ✅ **SHIPPED 2026-08-10 -- `threading.excepthook` is replaced, so every thread has the backstop the main thread already had.** Value **2/10** · Difficulty **2/10** · _fill-in_. `last_resort.install_thread_excepthook()` routes an exception escaping a non-main thread's `run()` through `safe_exc` to the filtered log, naming the thread and keeping the exception type; `serve()` installs it beside the existing `install_excepthook()`. `SystemExit` is ignored, matching the stdlib default -- a thread calling `sys.exit()` is a clean exit, not an error to report. The gap was structural rather than incidental: the interpreter routes a thread's escaping exception to `threading.excepthook` and nowhere else, so `sys.excepthook` could never have covered it and the guarantee had to be installed a second time. The motivating engine thread is the sandbox session's raw stdout reader, whose `except` catches only `OSError` by design. Measured with a live positive control reproducing that shape against both trees: pre-fix the stdlib hook wrote **838 bytes** of raw traceback ending in the full synthetic `PID|1||100^^^H^MR||DOE^JANE^Q||19800101|F`; post-fix the same run emits **93 bytes** reading `last-resort: uncaught exception in thread 'mefor-sandbox-reader': ValueError: PID|[redacted]`. The committed test drives a real thread rather than calling the hook directly, and asserts stderr carries no traceback at all. Original filing follows. **Cluster:** Error handling / PHI redaction. **Priority:** P3. **Verdict:** build (small). **Severity:** a 16.2.5-class redaction-quality gap, not a missing last-resort handler -- both stated purposes of ASVS 16.5.4 still hold (the details reach NSSM-captured stderr rather than being lost, and a dead daemon thread does not take down the process). On a deploying site an unexpected non-`OSError` in that thread could emit a traceback that skips the redaction filters. @@ -5154,7 +5154,7 @@ git -c 'alias.ci=commit --no-verify' ci -m x **Source:** the backtick and heredoc shapes from round 3's design pass, the self-referential-token class from its independent design review, the `cd` shapes from round 2's own "does not establish" list. ## 1077. `announce-session.ps1` skips reachable peers on `isRunning: false`, which does not mean dead -> 🔢 **Filed 2026-08-06 — not started.** Value **6/10** · Difficulty **2/10** · _quick win_. The announce hook instructs every session *"No exact row, or isRunning is false -> SKIP that peer"*, then has the model record the outcome under the token `NOT_RUNNING`. Measured 2026-08-06: a peer listed `isRunning: false` was **delivered to** and answered within one turn, while `isRunning: true` **queued** behind the in-flight turn. The field reads **backwards** as a reachability signal, so the rule drops exactly the peers most able to answer. +> ✅ **Shipped 2026-08-10.** The `isRunning` condition is **gone**: an exact `cwd` match is the whole reachability test, and the `NOT_RUNNING` receipt token went with it. The measurement now lives **once**, in [`scripts/coord/session-registry.ps1`](../scripts/coord/session-registry.ps1)'s header — the field's source of record, which already documented the correct reading while the hook contradicted it — and the hook plus [`WORKTREES.md`](WORKTREES.md) point at it instead of restating it. The WORKTREES.md paragraph that read `isRunning: true` for 1 of 6 registry-LIVE peers as a *reachability rate* is corrected: it was a count of who happened to be mid-turn. Tests assert the **emitted string**, because the hook's entire product is the text it puts in front of a model; both were watched red against the pre-fix script, and the absence assertion immediately earned its keep by failing a draft of this very fix whose own explanatory line re-printed the retired token. **Cluster:** Session coordination / roster semantics. **Priority:** P2. **Verdict:** build (small). **Severity:** no product effect and no security effect — this governs agent coordination. The cost is silent: a session follows the rule, reports fewer live peers than exist, and nothing raises. @@ -5181,7 +5181,7 @@ Both readings reach the same operational conclusion, which is the whole point of ## 1078. `new.ps1` prints cleanup advice that throws when followed as written -> 🔢 **Filed 2026-08-06 — not started.** Value **3/10** · Difficulty **1/10** · _quick win_. Run from a worktree, `new.ps1` anchors on `$PSScriptRoot` and creates the new tree beside **itself** — e.g. under `.claude/worktrees/` — then prints *"When done (run from the MAIN checkout): `scripts\worktree\remove.ps1 -Name `"*. From the main checkout that resolves to `/-`, which does not exist, and `remove.ps1` throws *"No such worktree"*. The command only works run from the worktree that created it, which is not what the line says. +> ✅ **Shipped 2026-08-10.** `new.ps1` now prints two cleanup commands, each carrying `$RepoRoot` explicitly so both run from any cwd **outside** the worktree: `pwsh -NoProfile -File "\scripts\worktree\remove.ps1" -Name ` (preferred — it keeps the uncommitted-tracked-changes guard) and `git -C "" worktree remove --force ""` (`--force` because the untracked `.venv` makes git consider the worktree non-empty). Placement is untouched, per the item; only the sentence changed. The same false claim lived in **two** more places — `remove.ps1`'s own header and `docs/WORKTREES.md` — and both are corrected in the same commit. The test **executes** the advice rather than reading it: `tests/test_worktree_new_cleanup_advice.py` extracts each printed command (an extraction contract `new.ps1` states on its side — `" # "` is a command, `" # "` is prose), substitutes the paths, and requires the worktree gone **and** deregistered. The control is live and in the same test: it builds a linked checkout `inner`, creates `inner-feature` beside it the way `new.ps1` would, and asserts the retired advice still throws `No such worktree` naming `/repo-feature` today — so the green is evidence the assertion can see the class. An end-to-end run of the real script from a linked worktree printed both commands with the linked root filled in, and the first removed the worktree verbatim. **Cluster:** Developer tooling / refusal and advice accuracy. **Priority:** P4. **Verdict:** build (trivial). **Severity:** low and loud — `remove.ps1` fails closed with a clear message naming the path it looked for. Nothing is destroyed and no wrong tree is removed; the cost is a confusing failure at cleanup time. @@ -5225,7 +5225,7 @@ Both readings reach the same operational conclusion, which is the whole point of ## 1087. `new.ps1` sets a worktree branch's upstream to the BASE, so every instrument keyed on `@{u}` answers a different question than the one asked -> 🔢 **Filed 2026-08-07 — not started.** Value **7/10** · Difficulty **1/10** · _quick win_. `new.ps1:133` runs `git worktree add -b ` with `-Base origin/main`. Git's default `branch.autoSetupMerge` then sets the new branch's upstream to the remote-tracking base, so **`@{u}` resolves to `origin/main`, not to the branch's own remote ref** — measured on a live worktree. The visible symptom is trivial. The consequence is not: **`@{u}..HEAD` reports a branch's own commits as "unpushed" forever, including immediately after a successful push.** +> ✅ **Shipped 2026-08-10.** `new.ps1` passes `--no-track` on the `worktree add`, so a new branch inherits no upstream and `@{u}` is unresolvable — failing **loudly** — until the first `git push -u origin ` sets it to the branch's own remote ref. `push.default` is untouched, and a test refuses any script that sets it (scanning every `.ps1` under `scripts/`, measured 38 files on 2026-08-10, with the count printed on failure). Measured on a synthetic repo with a real bare origin, branch tip byte-identical to its pushed remote — **before:** `@{u}` = `origin/main`, `@{u}..HEAD` = **1** (a false unpushed commit), and git's own remediation for a bare push read `git push origin HEAD:main`; **after:** `@{u}` exits 128 with *no upstream configured*, and after `push -u` it is `origin/` with a count of **0**. `tests/test_worktree_new_no_track.py` asserts the divergence per #1000 and carries the pre-fix command, written out literally, as the control in the same test — so it keeps reproducing the class whichever sanctioned fix the script later holds. Two stale premises the fix created are corrected in the same commit: `new.ps1`'s lock comment (the `.git/config.lock` race was measured **with** tracking on, and the upstream write its error text names is exactly what `--no-track` removes, so the lock **stays** until someone re-measures) and `prune-merged.ps1`'s signal 3, which named `new.ps1` as the source of the parent-upstream shape — it no longer is, and after `push -u` that signal starts working for a `new.ps1` branch, which it never did while the upstream was pinned to the base. **The flag reaches new worktrees only**; existing ones still carry `@{u} = origin/main`, and `docs/WORKTREES.md` gives the two measured remediations (`git push -u origin `, or `git branch --unset-upstream`). **Cluster:** Developer tooling / instrument accuracy. **Priority:** P2. **Verdict:** build (one flag). **Severity:** no product effect. The cost is that a routine safety question gets a confidently wrong answer, silently, with no error. @@ -5543,7 +5543,7 @@ So `-Com` and `-Comm` are working spellings. `-Cm` is not, which bounds the fami ## 1098. The coordination hook prints a session UUID where a commit SHA is expected -> 🔢 **Filed 2026-08-07 - not started.** Value **4/10** · Difficulty **2/10**. The parallel-session banner +> ✅ **Shipped 2026-08-10.** The registry session id is labelled **in every row**: `session ` on `session-context.ps1`'s live-peer roster and its in-flight list, and a named column header (`sess-id surface worktree branch`) on `presence.ps1`'s fixed-width table — a header governs every row, which is the same property. The ambiguity is **measured, not asserted**: the test keys on `git rev-parse HEAD` to prove the same banner really does print real abbreviated SHAs in that shape (` 8ba9b65 [master]`), so the claim rests on an object rather than a resemblance. Both tests read the EMITTED TEXT, which is the only place either defect can exist — every other test of these two scripts reads `-Json`, where the field is called `Short` and no ambiguity is possible. No consumer parses the human table: `mail.ps1`, `announce-session.ps1` and `session-context.ps1` all invoke presence with `-Json`, which returns before it. > printed at session start lists each live worktree with what reads as a commit SHA in the trailing > column. For at least one row the value is a **session UUID**: `061726a4` resolves to > `.git/mefor-coord/announce/061726a4-....json`, not to any object in the repository. The branch From 4edddeebfc5b91b99df82997778e39b2283c8532 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Mon, 10 Aug 2026 13:13:18 -0500 Subject: [PATCH 28/28] fix(worktree): the account-dir glob needs -Force, or Linux wires nothing (BACKLOG #1024) CI caught this on the ubuntu leg and no Windows run could have. Get-ChildItem omits hidden entries without -Force. On Windows a dot-prefixed directory carries no hidden ATTRIBUTE, so every ~/.claude-account-N enumerates either way. On Linux the dot prefix IS the hidden convention, so the glob returned NOTHING and the wire set collapsed to the single explicit ~/.claude candidate on the line above -- which is a Join-Path, not a glob, and so survived. That is exactly what the parity test reported: writer wires : ['.claude'] reader judges: ['.claude', '.claude-account-1', '.claude-account-42'] and it is the defect #1024 exists to close -- two enumerations of the same population disagreeing. Get-ConfigCandidates already passed -Force; this is the matching half. The anchoring introduced earlier is correct and unchanged. Also adds a cross-platform arm to the test file. The existing parity test can only go red where dot-dirs are hidden, so on Windows it is a claim rather than a control: the one instrument able to see this defect was a CI leg. The new test sets FILE_ATTRIBUTE_HIDDEN explicitly via attrib +H, making the class reproducible on the box the code is written on, and asserts loudly rather than skipping if the attribute cannot be set -- a silent skip would restore the blindness it removes. Red-first, on Windows, with -Force reverted: FAILED test_a_hidden_account_dir_is_still_wired "a HIDDEN ~/.claude-account-N was not wired" and the -Status audit printed "scanned 1 config dir(s)" with ORPHAN GATE WIRING in .claude-account-1 -- the same shape CI reported. Restored: 11 passed. --- scripts/worktree/install-gate.ps1 | 12 +++++++++- tests/test_install_gate_wiring.py | 37 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/scripts/worktree/install-gate.ps1 b/scripts/worktree/install-gate.ps1 index 50a11287..8d0e22ea 100644 --- a/scripts/worktree/install-gate.ps1 +++ b/scripts/worktree/install-gate.ps1 @@ -122,10 +122,20 @@ function Get-ConfigCandidates([string]$Root) { } # Config dirs to wire. Default: ~/.claude + every existing ~/.claude-account- (the VS Code launchers). +# +# -Force IS LOAD-BEARING AND ITS ABSENCE IS INVISIBLE ON WINDOWS. Get-ChildItem omits hidden entries +# without it. On Windows a dot-prefixed directory carries no hidden ATTRIBUTE, so every ~/.claude-account-N +# enumerates either way and the omission cannot be reproduced locally. On Linux the dot prefix IS the +# hidden convention, so this glob returns NOTHING and the wire set collapses to the single explicit +# ~/.claude candidate on the line above -- which is not a glob and so survives. That is precisely the +# shape CI reported on the ubuntu leg (writer wired ['.claude']; the reader, anchored by #199, judged +# ['.claude', '.claude-account-1', '.claude-account-42']), and it is why the parity test caught here what +# no Windows run could. Get-ConfigCandidates above already passes -Force for the same reason; the two +# enumerations must agree, and a difference between them is the exact defect #1024 exists to close. if (-not $ConfigDir -or $ConfigDir.Count -eq 0) { $cands = @( (Join-Path $HomeDir ".claude") ) $cands += @( - Get-ChildItem -LiteralPath $HomeDir -Directory -Filter ".claude-account-*" -ErrorAction SilentlyContinue | + Get-ChildItem -LiteralPath $HomeDir -Directory -Filter ".claude-account-*" -Force -ErrorAction SilentlyContinue | Where-Object { $LauncherName.IsMatch($_.Name) } | ForEach-Object { $_.FullName } ) diff --git a/tests/test_install_gate_wiring.py b/tests/test_install_gate_wiring.py index 971011a1..ad33fa25 100644 --- a/tests/test_install_gate_wiring.py +++ b/tests/test_install_gate_wiring.py @@ -333,3 +333,40 @@ def test_status_prints_a_sha_beside_each_version() -> None: assert re.search(r"\bv\d{4}\.\d{2}\.\d{2}\.\d+", src_line), ( f"version label missing: {src_line!r}" ) + + +def test_a_hidden_account_dir_is_still_wired(tmp_path: Path) -> None: + """BACKLOG #1024, cross-platform arm. The writer's account-dir glob needs ``-Force``, and its + absence is INVISIBLE ON WINDOWS. + + ``Get-ChildItem`` omits hidden entries without ``-Force``. On Linux the dot prefix *is* the hidden + convention, so the glob returns nothing and the wire set collapses to the single explicit + ``~/.claude`` candidate -- which is a ``Join-Path``, not a glob, and so survives. That is what the + ubuntu CI leg reported while every Windows run stayed green: on Windows a dot-prefixed directory + carries no hidden ATTRIBUTE, so the omission cannot fire. + + This test makes the class reproducible on Windows by setting the attribute explicitly, so the + control can go red on the box the code is written on. Without it, the only instrument that can see + this defect is a CI leg -- and a check that cannot fail where you work is a claim, not a control. + """ + names = [".claude", ".claude-account-1"] + home = _fake_home(tmp_path, names) + + hidden = home / ".claude-account-1" + if os.name == "nt": + # FILE_ATTRIBUTE_HIDDEN. Fail loudly rather than skipping: a silent no-op here would restore + # exactly the blindness this test exists to remove. + rc = subprocess.run( + ["attrib", "+H", str(hidden)], capture_output=True, text=True, timeout=30 + ) + assert rc.returncode == 0, f"could not hide the fixture dir: {rc.stderr or rc.stdout}" + # On POSIX the leading dot already makes it hidden to PowerShell; nothing to do. + + out = _status_against(home) + wired_lines = [ln for ln in out.splitlines() if ln.startswith("wiring :")] + wired = {Path(ln.split(":", 1)[1].strip()).parent.name for ln in wired_lines} + assert ".claude-account-1" in wired, ( + "a HIDDEN ~/.claude-account-N was not wired -- the writer's Get-ChildItem is missing -Force.\n" + f" wired: {sorted(wired)}\n" + " On Linux every dot-dir is hidden, so this is the whole account population, not an edge case." + )