Skip to content

build(deps)+ci: hash-pin the CI scanner toolchain via PEP 735 groups - #63

Merged
wshallwshall merged 7 commits into
mainfrom
pins
Jul 30, 2026
Merged

build(deps)+ci: hash-pin the CI scanner toolchain via PEP 735 groups#63
wshallwshall merged 7 commits into
mainfrom
pins

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

ADR 0034 §3's convergence target, built. CI tooling is now declared as PEP 735 [dependency-groups], flows into uv.lock, and is consumed as hash-pinned uv exports — because a version pin does not satisfy Scorecard (this repo's own data: bandit==1.9.4 and zizmor==1.5.2 were exactly pinned and still flagged, while --require-hashes installs are flagged in neither set).

Deliberately out of scope

  • release.yml: zero lines changed. Verified. No PR leg executes it, so a break first surfaces at a tag.
  • sigstore stays 4.4.0 — and is now structurally unreachable at 4.5.0: 0 hits in uv.lock and in all six exports. Taking 4.5.0 would invert cooldown.default-days: 5 at the highest-privilege point in the build.
  • The uv bootstrap is circular by construction — that pip produces every lock and runs the diff gate. Permanent residual, recorded.
  • pipx install ruff has no --require-hashes and generates no alert; the guard was taught to see pipx regardless.

The adversarial pass found 7 holes in the change itself

Worth reading, because several were invisible:

  1. The == pin was enforced by nothing. Moving bandit/pip-audit out of RELEASE_PINNED_TOOLS deleted the only check rejecting a floor. Rewriting all three scanner specs to >= re-locked, re-exported byte-identically, and passed every guard in the repo.
  2. The export selector was unpinned. --group instead of --only-group grew ci-scanners.lock 33 → 69 requirements, pulling fastapi, uvicorn, hl7, httpx, aiosqlite into what the blocking bandit and zizmor jobs install — fully pinned, fully hashed, byte-identical under DEP-1, nothing red.
  3. --no-dev is not the protection it looks like — it disables only the group literally named dev. Adding the scanners to default-groups leaks them into requirements.lock (98 → 121) and docker/locks/requirements-core.lock (41 → 69), the SBOM input, straight past its --no-dev. Under that leak 109 guards passed and only the new one went red.

Plus: pipx was invisible to the guard's regex (now both spellings across all 16 workflows), _locked_requirements silently skipped non---hash directives, and four inexact counts became exact.

Verification

12 injected regressions → 12 red, clean revert each time. Full suite 9476 passed, 817 skipped, 0 failed. Both group locks install under --require-hashes at exit 0, zero source builds, and pip-audit reports no known vulnerabilities in either.

DEP-1 re-run verbatim: uv lock --check exit 0, six exports, git diff --exit-code exit 0. The four pre-existing artifacts moved zero bytes; uv.lock is 588/0 — purely additive, so a version/hash/edge change is impossible.

YAML skeletons compared structurally against origin/main: 16 examined, 0 differing, with the comparator proven able to fail (injected contents: write → detected). Only run: bodies changed.

Honest scope note

This closes no open alert. The three affected findings are already in the dismissed set, so the open count won't move — the effect is that three dismissals with a now-false reason become re-triageable as fixed. And Scorecard is a scheduled scan against main, so closure isn't observable before merge.

…two hashed exports

A version pin does NOT satisfy Scorecard's PinnedDependenciesID. This repo's own alert
data proves it: bandit==1.9.4 (#74) and zizmor==1.5.2 (96) are exactly pinned and still
flagged, while the two --require-hashes installs are flagged in neither the open nor the
dismissed set. ADR 0034 section 3 named routing CI tooling through a pyproject dependency
group as the convergence target and deferred it. This is the declaration half.

Two groups, split on THE MERGE PATH:
  ci-scanners  bandit / pip-audit / zizmor        -> the BLOCKING security gates
  ci-quality   diff-cover / mutmut / pytest-cov /
               pytest-timeout                     -> ADVISORY measurement

Splitting them keeps mutmut -- a mutation engine that rewrites and executes source -- out
of every required gate's install closure.

Exported hash-pinned to ci/locks/ci-scanners.lock (33 reqs, 163 hashes) and
ci/locks/ci-quality.lock (27 reqs, 164 hashes). Each pin's rationale sits beside the pin
rather than in a workflow comment, because a comment in security.yml pushes every
dismissed alert anchored below it onto a new line number (ADR 0034's convergence rule).

NOT extras (an extra is published wheel metadata) and NOT in [tool.uv] default-groups (a
default group would land in the release SBOM, the container image locks, and in what
pip-audit audits AS RUNTIME). Verified: with these groups non-default all four pre-existing
DEP-1 artifacts re-export byte-identically -- `git diff --exit-code` exit 0.

semgrep is deliberately EXCLUDED: it requires opentelemetry-sdk>=1.37,<1.38 while the
project's [otel] extra resolves 1.44, so a plain group silently downgrades the shipped
otel runtime in all four artifacts. The only fix ([tool.uv] conflicts) would declare a
product extra and a CI scanner permanently mutually exclusive. Recorded as a reasoned
residual with the exact recipe in the ADR amendment.

Verified: uv lock --check exit 0; both locks install with `pip install --require-hashes`
into a fresh venv at exit 0, zero source builds, all six tools runnable at the pinned
versions; the group locks and constraints.lock agree on all 20 shared packages (pytest and
pytest-timeout among them), so a hashed tool install cannot re-point what the coverage run
executes under.

.gitattributes also gains ci/locks/*.lock and -- a latent bug found while measuring --
constraints.lock, which had `text: unspecified` and so checked out CRLF under
core.autocrlf=true, exactly the drift that stanza exists to prevent.
declared_distributions() read [project].dependencies + [project.optional-dependencies]
only. The six CI-toolchain names just declared in [dependency-groups] would therefore have
entered the tree UNVETTED, and so would every future group entry -- the gate would have
gone silently blind on a whole table while still reporting a clean sweep.

This is not a bookkeeping fix. Those names are resolved into uv.lock and installed into the
runner that executes the BLOCKING security gates, which makes a squatted scanner name a
SHARPER target than a runtime dependency, not a softer one: it runs with the job's token in
hand before any of these controls report.

PEP 735 lets a group entry be a {include-group = "other"} table rather than a requirement
string. Those are skipped -- they name another group, not a distribution, and handing the
dict to requirement_name() would raise and take the whole gate down. The included group's
own members are still swept, because every group is iterated regardless of who includes it.

Three new tests prove the table is PARSED and separately that it is ENFORCED (a hallucinated
name declared in a group must fail vetting end-to-end), plus a liveness test asserting the
examined count actually MOVED -- as a set delta, not a magic total, so an ordinary dependency
tomorrow does not red it but the table falling out of the parser does.

Measured: examined 41 -> 47 distributions, added exactly
{bandit, pip-audit, zizmor, diff-cover, mutmut, pytest-cov}. pytest-timeout adds nothing
because it is deliberately declared in BOTH the dev extra and ci-quality with the identical
spec, which is what stops uv resolving two versions of it.

Verified against live PyPI: all 47 exist, publish, and are established (exit 0).
…rds that see it

Consumes the two PEP 735 exports. Five install sites become
`pip install --require-hashes -r ci/locks/<group>.lock`:

  security.yml  pip-audit step   (was pip install --upgrade pip "pip-audit==2.10.1")
  security.yml  bandit step      (was pip install --upgrade pip "bandit==1.9.4")
  zizmor.yml    zizmor install   (was pip install --upgrade pip "zizmor==1.5.2")
  quality-advisory.yml  coverage (was uv pip install pytest-cov "diff-cover==10.4.1")
  quality-advisory.yml  mutation (was uv pip install "mutmut==3.6.0" pytest-timeout)

Two unpinned `--upgrade pip` bootstraps DISAPPEAR rather than get pinned: ci-scanners.lock
hash-pins pip==26.2 itself (a pip-audit -> pip-api dependency), so a hash-verified pip now
arrives in the same command that used to fetch an unverified one.

`python -m pip`, not `uv pip install --system`, in quality-advisory.yml -- deliberately.
--require-hashes is pip's contract, and Scorecard's pinned-dependency parser keys on
pip/pip3/`python -m pip`, so `uv pip` would be invisible to the very check this exists to
satisfy.

pip-audit now also audits both toolchain locks. This is load-bearing, not tidiness:
hash-pinning makes a toolchain STICKY, so without it a CVE in a pinned scanner is invisible
to every gate -- the "pinned, stale, unpatched is worse than floating" failure ADR 0034
section 3 names. It is also the mitigation for an UNVERIFIABLE unknown: whether Dependabot's
uv ecosystem enumerates [dependency-groups] at all cannot be tested before the next weekly
run, and this converts "silently stale" into a red gate within ~24h via the daily cron.

Six-place lockstep per new lock (export + git diff in the gate; export + git diff --quiet +
git add in dependabot-lock-resync.yml), enforced by test_dep1_lock_resync_lockstep.py. The
resync's header count goes FOUR -> SIX.

Guards taught, in the same commit their own failure messages demand:
  * bandit and pip-audit dropped from RELEASE_PINNED_TOOLS (their pins moved into the lock).
    uv and semgrep STAY -- uv cannot be hash-locked by uv (circular: it produces the locks),
    semgrep is excluded by decision. So the table stays non-vacuous.
  * test_diff_cover_is_pinned_exactly / test_mutmut_is_pinned_to_3x_with_pytest_timeout
    re-pointed at the group + the lock, keeping their BEHAVIOURAL assertions (mutmut must be
    3.x; pytest-timeout must be present) asserted against what is actually installed.

Five NEW tests, because `-r <lock>` is INVISIBLE to _install_targets (it skips -r and its
argument), so these lines passed every existing scan by not being looked at:
require-hashes present; the lock fully ==-pinned AND hashed PER REQUIREMENT; the lock is in
the DEP-1 export set (the anti-rot guard); every moved tool still declared in a group; no
moved tool also installed inline.

That per-requirement hash check earned itself: the first version asserted
`"--hash=sha256:" in text`, which stayed GREEN under an injected regression while its six
sibling guards all went red -- a lock with 33 pinned packages and one hash would have passed.

Evidence (each guard was made to fail on purpose first): 8 injected regressions, 8 RED as
designed, clean revert. 128 workflow shell blocks bash -n clean. Parsed-YAML diff vs
origin/main: triggers, top-level and per-job permissions, per-job if/continue-on-error and
step counts ALL IDENTICAL in all four workflows -- only run: bodies changed. DEP-1's exact
six-artifact `git diff --exit-code` exits 0. Full suite 9464 passed.

NOT TOUCHED, deliberately: release.yml (zero edits -- no PR CI leg executes it, so a break
first surfaces at a tag; its sigstore==4.4.0 cooldown decision stays intact) and the uv
bootstrap (circular by construction). Both recorded as residuals in the ADR amendment.
…e every residual

Amends ADR 0034 (no new number minted; docs/BACKLOG.md untouched). Supersedes section 3's
"Deferred, recorded here as the convergence target", the "Option B ... remains the only thing
that closes the alert" sentence in Recommended hardening, and the "four committed lock
artifacts" count -- there are now six.

Records what a dismissal needs to stay dismissed honestly: the reason, visible.

WHAT CLOSES -- and the uncomfortable part first: this does NOT reduce the open count. The
three lines converted (bandit #74, zizmor 96, pip-audit) are all in the DISMISSED set, so the
real effect is that three dismissals whose recorded reason ("CI installs editably, which
cannot use --require-hashes") is now FALSE for those lines become re-triageable AS FIXED.
Under this ADR's own Decision -- a dismissal with a false reason is worse than an open
finding -- that is the point, not a consolation. The 2 genuinely OPEN PinnedDependenciesID
alerts are the SBOM scratch-venv cyclonedx-bom pair, and closing them requires moving both
release.yml and security.yml in lockstep (the byte-identity test forces it). Recorded as the
next increment, not claimed.

RESIDUALS, each with its reason rather than implied:
  * sigstore==4.4.0 -- NOT a gap but an owner decision this change must not invert. The lock
    resolves 4.5.0; the pin is 4.4.0 because dependabot.yml sets cooldown.default-days: 5.
    Routing it through the lock would pin the SIGNING toolchain fresher than the repo's own
    policy allows, at the highest-privilege point in the build. Left exactly as it is.
  * the uv bootstrap -- permanently circular: you cannot hash-lock uv with uv. Includes the
    cheap out-of-band fix that would remove it (setup-uv is already SHA-pinned in 9 places).
  * pipx install ruff -- outside the guard's _PIP_INSTALL regex AND outside Scorecard's, so
    there is no alert to close; pipx has no --require-hashes.
  * semgrep -- excluded by decision, with the exact [tool.uv] conflicts recipe written down
    so a future owner can flip it in one commit instead of re-deriving the analysis.
  * the editable installs -- structurally unhashable; section 3's original rationale stands.

Also recorded rather than glossed:
  * The convergence-rule cost. security.yml gained ~9 lines near the top, so every dismissed
    alert anchored below re-fires as a new number. NO claim of line-neutrality is made.
  * The one evidence gap and its bound: dependabot-lock-resync.yml skips on a human PR, so
    its new export lines are text-verified only until the next Dependabot uv PR -- bounded by
    the flag-identity test plus the fact that the GATE does execute on this PR.
  * Dependabot's PEP 735 visibility is UNVERIFIED and unverifiable pre-merge.

New AC-8 with a link to the tests that verify it, so adr-analyze resolves it.
The adversarial pass found no wrong byte in any lock -- `uv lock --check` exit 0,
all six exports byte-identical, the uv.lock diff 588 insertions / 0 deletions. Every
finding was a rule the prose asserted and nothing checked. Each hole below was
reproduced by injecting the regression and confirming the suite stayed GREEN, then
closed and confirmed RED.

1. THE `==` PIN WAS ENFORCED BY NOTHING. Moving bandit/pip-audit out of
   RELEASE_PINNED_TOOLS deleted the only check that rejected a floor (_PIN_OPS
   excludes `>=` by design). Neither replacement can see one: asserting the NAME is
   declared does not, and asserting the EXPORTED lock is `==`-pinned cannot, because
   `uv export` writes `bandit==1.9.4` from `bandit>=1.9.4` just as readily. Proven:
   all three scanner specs rewritten to `>=` re-locked, re-exported the two toolchain
   locks BYTE-IDENTICALLY, and passed every guard in this repo. EXACT_GROUP_PINS +
   FLOOR_BY_DESIGN now assert the shape at the declaration, where the decision lives.

2. THE EXPORT SELECTOR WAS UNPINNED. `uv export .* -o <lock>` accepts `--group` for
   `--only-group`, and `--group` is additive: it grew ci-scanners.lock from 33 to 69
   requirements, pulling fastapi/uvicorn/hl7/httpx/aiosqlite into what the BLOCKING
   bandit and zizmor jobs install -- fully pinned, fully hashed, byte-identical under
   DEP-1, nothing red. The command is now matched exactly, with the group required to
   match the lock's stem, and test_each_group_pin_reaches_its_own_lock ties the two
   together from the other direction.

3. FIVE INSTALL SITES, THREE TABLE ROWS, CHECKED WITH ">= 1". Four of the five were
   individually deletable at zero test cost. The coverage job's failure is the
   dangerous one because it is silent: `pytest -q --cov` dies on `unrecognized
   arguments`, `|| true` swallows it, and diff-coverage reports "skipped" and exits 0.
   The counts are now exact, and the two quality-advisory assertions resolve under
   `jobs.coverage` / `jobs.mutation` instead of searching the whole file.

4. THE BOOTSTRAP INVENTORY SAID ONE; THE FILE HAS TWO (the uv install and the semgrep
   step). SECURITY_YML_ACCEPTED_UNPINNED registers the NAME `pip`, so it cannot tell
   two accepted bootstraps from twenty -- now an exact count.

5. THE INLINE-REINSTALL SCAN MISSED `pipx install` (in-repo idiom already: ruff, twice)
   and swept only three workflows, so `pip install bandit` in ci.yml was invisible. Now
   both spellings across all 16 workflows.

6. `_locked_requirements` SILENTLY SKIPPED any non-`--hash` directive -- an
   `--index-url` redirect or an `-e .` line was neither counted nor rejected. Raises now.

7. NOTHING GUARDED THE LOAD-BEARING NON-DEFAULT-GROUP INVARIANT, and `--no-dev` is not
   the protection it looks like: it disables only the group literally NAMED `dev`.
   Measured -- `default-groups = ["ci-scanners"]` puts the scanners into
   requirements.lock (98 -> 121) AND into docker/locks/requirements-core.lock
   (41 -> 69), the SBOM input, straight past its `--no-dev`; the two `--all-extras`
   exports pass neither `--no-dev` nor `--no-default-groups`, so they are unguarded by
   construction. Under that leak 109 guards passed and only the new one red.

12 injected regressions, 12 red, clean revert. Full suite 9476 passed / 817 skipped / 0
failed; ruff + ruff format clean; the DEP-1 six-artifact `git diff --exit-code` exit 0.
ADR 0034's own Decision is that a dismissal carrying a false reason is worse than an
open finding. These four were recorded reasons, so they get held to that.

security.yml, the pip-audit step: "the `--upgrade pip` bootstrap is GONE rather than
pinned" is TRUE of the bandit job and zizmor.yml, and FALSE of the job it was written
in. security.yml:66 still runs `pip install --upgrade pip "uv=="` in the same
interpreter two steps earlier, and THAT unverified pip does the load-bearing work: six
`uv export`s plus the `git diff --exit-code` gate. The hashed install then DOWNGRADES
pip to 26.2 after the fact. Scoped to say so; the register already conceded one
bootstrap survives, so the file contradicted itself.

quality-advisory.yml, both install steps: the recorded rationale for `uv pip` ->
`python -m pip` was unsound in both halves. `uv pip install --require-hashes` exists
(env UV_REQUIRE_HASHES) and setup-uv is already in both jobs, so it is not "pip's
contract". And the Scorecard half argues the wrong direction -- a line its parser
cannot read generates NO finding, so converting one can only ADD parseable surface, not
close an alert. The change is kept (one spelling for every hashed install in the repo,
one thing for the guards to match) with that as the stated reason.

ADR 0034, the sigstore residual: "The lock resolves `sigstore` to 4.5.0" is false as
written -- `sigstore` appears NOWHERE in uv.lock or in any of the six exports (0 hits);
4.5.0 was measured in a scratch resolve. Restated counterfactually, because a reader
who checks the claim, finds nothing, and concludes the row is stale would re-open the
cooldown decision the row exists to preserve.

docs/Code_Quality_Standards.md:312 was the one copy of "they needed NO requirements.lock
change -- DEP-1 unaffected" that escaped the sweep (quality-advisory.yml's copy was
rewritten). Both halves are now false. Dated correction rather than a rewrite: it sits
inside a dated rollout record.

Also in ADR 0034:

- The line-drift figure was a guess ("~9") and understated the re-anchor budget by two
  thirds. Measured per workflow instead: security.yml 464 -> 490 (+26), quality-advisory
  619 -> 637 (+18), the resync +9, zizmor +1, each with its first changed line.
- A second residual row for the unpinned `pip` in the semgrep step. The semgrep row
  explained only the [otel] conflict; the same line is also an unpinned pip fetch, and
  an inventory that undercounts is how a real finding goes invisible. Mitigating:
  semgrep is not a required context.
- The pip-audit consequence, with the measurement that frames it, because this one is
  the owner's call. It is NOT a new posture -- requirements.lock is `--all-extras`, so
  ruff/mypy/pytest already sit in the blocking audit's input -- but it IS a wider blast
  radius: 40 distributions new to the required gate (23 via ci-scanners, 17 via
  ci-quality). Measured 0 advisories across all 60 name==version pairs. The one-line
  retreat, if wanted, is to move the ci-quality audit to its own step and keep the
  scanners' blocking.
- A table of the seven guard holes the adversarial pass found, what passed green with
  each regression in place, and what now enforces it.
@wshallwshall
wshallwshall enabled auto-merge (squash) July 30, 2026 02:38
@wshallwshall
wshallwshall merged commit 0a9e446 into main Jul 30, 2026
34 checks passed
@wshallwshall
wshallwshall deleted the pins branch July 30, 2026 03:03
wshallwshall added a commit that referenced this pull request Aug 4, 2026
…6-08-03)

Every open item now carries a current value x difficulty score. Before this, 23 had
none at all and the other 69 were from the frozen 2026-07-10 pass, which predates the
2026-07-28 reconcile that closed 31 items -- and a stale score reads exactly like a
fresh one.

Method, unchanged from the pass it supersedes: scored from each item's own Scope /
Why / Trigger / Nearest-existing-mechanism text rather than rescaled from the old
number, then adversarially verified against the code -- a second reader per batch
attacking build state first, then verdict/tier, then value and difficulty. 26 of 92
scores were overturned by that pass and carry the refuter's number.

The banner is the live record and the table is a view of it; both are written here and
a mechanical check confirms 92 banners and 92 rows agree on every triple.

THE RATIONALE IS REPLACED, NOT JUST THE NUMBERS. Carrying an old justification under a
new score is how a banner comes to argue against itself:
  - #114's surviving "clean workaround via the on-demand test probe" is a claim PR #162
    explicitly retracted -- both destinations' test_connection CREATE the target dir, so
    the probe cannot answer the question the toggle asks. That is what lifts it off the
    parity-with-a-workaround band to 6/3. Its replacement rationale was ALSO stale (it
    described the silent-ignore #162 had just fixed) and is hand-corrected.
  - #105's "large greenfield 71-action mapper needing its own ADR" describes an importer
    that has since shipped under ADR 0086.

Scheduling barely moved, which is the reassuring result: only TWO tiers changed --
#64 DEMAND-GATE -> P3 (an index over levers that live in #62/#63/#47/#34, so it ships
nothing runnable of its own) and #105 P3 -> DEMAND-GATE. Neither contradicts an
explicit demand-gate/on-trigger ruling in its own body; that was checked for all 51
items carrying a prior tier.

Distribution is RECOMPUTED with the table rather than carried forward, and all four
lines sum to 92. The superseded table keeps its own frozen lines and now says so.

  Tiers: P1 4, P2 19, P3 17, DEMAND-GATE 52
  Quadrants: quick win 22, big bet 5, fill-in 56, money pit 9

The four P1s: #341 (9/3, a handler returning a tuple/set of Sends delivers nothing
silently -- an accept-and-drop CLAUDE.md §12 forbids), #324 (7/2), #325 (6/2), #327 (6/2).

NOT in this commit: 24 items were found to misdescribe their own build state -- prose
asserting a gap that has since shipped, or citing messagefoundry/console/, a package
retired with #103. Those are banner corrections and land separately; the scores here
already price the remainder rather than the original scope.

Two mechanical faults were caught by reading the output rather than trusting the run:
the quadrant regex omitted the hyphen in "fill-in", so 57 of 69 items took the fallback
branch and got a SECOND score inserted beside the first; and the synthesizer's own
distribution lines did not follow from its own table (11 quadrant mismatches, 8
ordering violations, difficulty summing to 95 of 92). The script now refuses to write
when any line carries two score spans or the scored count is not 92.

backlog_status_check.py --min-items 277: OK, 277 items across both files.
wshallwshall added a commit that referenced this pull request Aug 4, 2026
…ones (#163)

* docs(backlog): close BACKLOG #226 — the estate Hybrid-layout sweep is done, off-repo

The per-feed Hybrid split (connections.toml / <INBOUND>_router.py /
<INBOUND>_handler.py / _<feed>_transforms.py) landed across the ported estate in
the maintainer-internal migration repository. Owner-attested; nothing in this
repository changes, which is also why leaving the item open could never have
closed it.

Both "Also" clauses are recorded as NOT delivered, with the reason each is not a
residual of this item:

  - "align the IDE Corepoint-import / scaffold path to emit the Hybrid layout" —
    there is no Corepoint-import path in ide/ to align. That tooling is #105,
    still open, so the clause is a constraint on #105's design rather than work
    #226 can perform. The scaffold half is misaddressed too: Insert Element (#48)
    drops per-file idioms into the current buffer (ide/src/insertElement.ts:1-5)
    and emits no multi-file feed layout.

  - "consider a recursive-glob / folder-per-feed loader enhancement" — filed as a
    consider, and not taken: load_config still globs *.py non-recursively
    (config/wiring.py:4162), the flat-merge behaviour the Hybrid layout is built
    around.

Follows the #227 precedent: close the primary, state the off-repo/misaddressed
residuals explicitly so the item is not re-opened for them.

backlog_status_check.py: OK — 277 items, each declaring exactly one status.

* fix(ledger): teach the number-space gates to span an archive, and fix two holes found proving it

Prerequisite for moving the 185 closed BACKLOG items into docs/archive/backlog/.
No item has moved yet; this only makes the guards able to see one when it does.

The item namespace will span two paths, so every guard now reads their UNION:

  - backlog_status_check.py: scan() takes (label, text) pairs and parses them as ONE
    namespace. A number re-used across BACKLOG.md and the archive was structurally
    undetectable before -- `seen` was per-parse -- which is the erratum's own shape.
  - ledger_check.py: triggers on any backlog-bearing path, not the one literal, and
    builds head/base as the union. Reading the union on both sides also removes a
    false positive: the move relocates 185 items, so head-union == base-union and
    `head - base` stays empty, where a per-file view would report 185 vanished
    numbers with a remedy that renumbers cited items.
  - alloc.ps1: sweeps both paths in the all-refs term and the working-tree term.
  - backlog-hygiene.yml: accepts a banner updated in either location.

Two pre-existing defects surfaced only because the gates were made to fail on
purpose first, neither of which is about the archive:

  1. alloc.ps1's working-tree term has NEVER worked. `[regex]'^...'` anchors at the
     start of the STRING; the term feeds it `Get-Content -Raw`, one string starting
     "# Backlog". Measured: 0 of 277 headings matched without Multiline, 277 with.
     The all-refs term hid it by covering every number committed somewhere -- i.e.
     every case except the uncommitted one this term exists for.
  2. backlog-hygiene.yml diffed BASE_SHA..HEAD_SHA (two-dot), which credits a PR for
     main-side changes to paths it never touched. One main-side edit to BACKLOG.md
     -- the move being a large one -- would let every PR with an older base pass the
     "must update BACKLOG.md" required check while enforcing nothing. Now three-dot,
     matching ci.yml's form for the same question.

Anti-narrowing, because a green gate over a shrunken corpus is the failure mode:
  - `--min-items N` fails when fewer items are found than required, and CI pins 277.
    Without it, 277 -> 92 fails nothing.
  - The scanned files are always printed with the count; a bare integer cannot
    distinguish "items closed" from "a file stopped being read".
  - A liveness receipt in the test suite asserts the same floor.
  - An explicitly-named --backlog path that does not exist is an error, not a skip.

alloc.ps1 gains `-ShowFloor`: print the floor and the swept paths, allocate nothing.
Allocation is a one-way door, so before this the only way to ask what the floor could
see was to spend a number on the question -- which is how it ran a whole release
reading two refs while its header promised all of them. Get-Floor takes -Peek so the
inspection cannot advance the high-water ratchet; the first -ShowFloor run against a
planted number moved this clone's watermark 316 -> 990 before that was fixed.

Proofs run, each observed failing BEFORE the fix:
  - archive-only unallocated #1007 staged: old gate rc=0, new gate BLOCKED.
  - #990 planted in the archive: old sweep floor 353 (blind), new sweep 990.
  - cross-file duplicate #118: detected, naming the other file.
  - banner violations inside the archive only: detected.
  - --min-items over a narrowed corpus: rc=1 with the scanned-file list.
  - -ShowFloor twice against a plant: watermark unchanged at 316.

ruff + mypy --strict clean; 43 gate tests pass.

* docs(backlog): move the 185 closed items into docs/archive/backlog/BACKLOG-CLOSED.md

docs/BACKLOG.md becomes the ~92 items someone can act on: 8,742 -> 3,648 lines.
The closed items are not deleted, summarised, or rewritten -- they are relocated
verbatim, so the file that gets opened, grepped and edited daily is the open set.

MOVED, NOT REWRITTEN. Every relocated block is byte-identical to the one that left
BACKLOG.md, headings included. Verified mechanically against a pre-move copy:

  - 277 items before = 92 after + 185 archived, no overlap, union identical
  - every OPEN block byte-identical to its source
  - every ARCHIVED block byte-identical to its source
  - all non-item prose in BACKLOG.md preserved verbatim

Byte-identical headings are load-bearing, not tidiness: GitHub derives anchor slugs
from heading text, so all 64 archived->archived cross-references keep resolving with
no edit at all. That is the whole argument for one archive file rather than a split
by status, year, or cluster -- #52 alone receives 99 of the 110 in-file anchors, and
its citers span #65 to #184, so no cut isolates them.

Cutting item blocks at the next '## ' heading of EITHER kind, not the next numbered
item: 4 blocks in this file are followed by a section header, which a naive cut would
have dragged into the archive along with the prose beneath it.

Anchors, all 127 re-resolved against real headings after the edit:
  - 44 rewritten in BACKLOG.md   -> archive/backlog/BACKLOG-CLOSED.md#<same-slug>
  -  1 rewritten in the archive  -> ../../BACKLOG.md#<same-slug>  (#226 -> #105)
  -  3 cross-file links repointed: AOAG-DEPLOYMENT.md (#100, #101), ADR 0026 (#30)
  - 64 archived->archived untouched, by design

13 anchors still do not resolve, and ALL 13 WERE ALREADY DEAD BEFORE THIS COMMIT --
confirmed by running the same check over the pre-move file, which returns the
identical multiset (11 bare-number self-anchors: #40 x4, #323 x3, #28, #29, #329,
#333; plus 2 links to #13 in COUNSEL-ENGAGEMENT-BRIEF.md, a number this sequence
never had). They are left dead and documented in the archive header rather than
repointed at a plausible neighbour: a citation resolving to the WRONG item is the
erratum's failure mode, and unlike a dead link it looks like success.

The archive carries its retirement banner inline rather than in a sibling README --
docs/archive/throughput/ needs a README because it indexes five documents; one file
does not, and two documents that must agree is a drift surface. It states the rules
that keep the namespace honest: never renumber, re-open by moving the block back
(never by copying, which creates the cross-file duplicate the status check now
fails), and add any future archive file to alloc.ps1's $backlogPaths AND
backlog_status_check.py's DEFAULT_SOURCES in the same commit -- a file named in
neither is policed by nothing.

Gates verified post-move:
  - backlog_status_check.py --min-items 277: OK, 277 items, and it now PRINTS
    "scanned: docs/BACKLOG.md (92), docs/archive/backlog/BACKLOG-CLOSED.md (185)"
  - ledger_check.py on the staged move: rc=0 (relocation adds no numbers, because
    head-union == base-union -- the exact false positive the union view removes)
  - alloc.ps1 -ShowFloor: floor 353 across both paths, next 1000
  - 43 gate tests pass

Note the floor is unchanged at 353 because the highest item (#353) is open and stays
in BACKLOG.md. The archive-sweep fix is therefore PROSPECTIVE, not a save: it starts
mattering the first time a top-of-range item closes and moves.

* docs(backlog): re-score all 92 open items on the ten-level scale (2026-08-03)

Every open item now carries a current value x difficulty score. Before this, 23 had
none at all and the other 69 were from the frozen 2026-07-10 pass, which predates the
2026-07-28 reconcile that closed 31 items -- and a stale score reads exactly like a
fresh one.

Method, unchanged from the pass it supersedes: scored from each item's own Scope /
Why / Trigger / Nearest-existing-mechanism text rather than rescaled from the old
number, then adversarially verified against the code -- a second reader per batch
attacking build state first, then verdict/tier, then value and difficulty. 26 of 92
scores were overturned by that pass and carry the refuter's number.

The banner is the live record and the table is a view of it; both are written here and
a mechanical check confirms 92 banners and 92 rows agree on every triple.

THE RATIONALE IS REPLACED, NOT JUST THE NUMBERS. Carrying an old justification under a
new score is how a banner comes to argue against itself:
  - #114's surviving "clean workaround via the on-demand test probe" is a claim PR #162
    explicitly retracted -- both destinations' test_connection CREATE the target dir, so
    the probe cannot answer the question the toggle asks. That is what lifts it off the
    parity-with-a-workaround band to 6/3. Its replacement rationale was ALSO stale (it
    described the silent-ignore #162 had just fixed) and is hand-corrected.
  - #105's "large greenfield 71-action mapper needing its own ADR" describes an importer
    that has since shipped under ADR 0086.

Scheduling barely moved, which is the reassuring result: only TWO tiers changed --
#64 DEMAND-GATE -> P3 (an index over levers that live in #62/#63/#47/#34, so it ships
nothing runnable of its own) and #105 P3 -> DEMAND-GATE. Neither contradicts an
explicit demand-gate/on-trigger ruling in its own body; that was checked for all 51
items carrying a prior tier.

Distribution is RECOMPUTED with the table rather than carried forward, and all four
lines sum to 92. The superseded table keeps its own frozen lines and now says so.

  Tiers: P1 4, P2 19, P3 17, DEMAND-GATE 52
  Quadrants: quick win 22, big bet 5, fill-in 56, money pit 9

The four P1s: #341 (9/3, a handler returning a tuple/set of Sends delivers nothing
silently -- an accept-and-drop CLAUDE.md §12 forbids), #324 (7/2), #325 (6/2), #327 (6/2).

NOT in this commit: 24 items were found to misdescribe their own build state -- prose
asserting a gap that has since shipped, or citing messagefoundry/console/, a package
retired with #103. Those are banner corrections and land separately; the scores here
already price the remainder rather than the original scope.

Two mechanical faults were caught by reading the output rather than trusting the run:
the quadrant regex omitted the hyphen in "fill-in", so 57 of 69 items took the fallback
branch and got a SECOND score inserted beside the first; and the synthesizer's own
distribution lines did not follow from its own table (11 quadrant mismatches, 8
ordering violations, difficulty summing to 95 of 92). The script now refuses to write
when any line carries two score spans or the scored count is not 92.

backlog_status_check.py --min-items 277: OK, 277 items across both files.

* docs(backlog): correct 10 items whose own prose misdescribed build state

The 2026-08-03 re-score flagged 24 open items as misdescribing what the code does.
Re-verified each against the tree as it stands -- after the archive move and after
PR #162, both of which post-date the findings -- and 10 survived. The other 14 did
not, and are recorded here rather than silently dropped:

  #84 #95 #99 #105 #114 #124 #125 #127 #133 #137 #167 #169 #214 #228

Most of those already carry an amendment that covers the stale sentence (#95, #99,
#105, #114, #124, #125, #127, #133, #228), and stacking a second ruling saying the
same thing is noise. The rest did not survive verification: the finding was itself
wrong or overstated, and a wrong correction in a ledger is worse than a stale one.

CORRECTIONS ARE ADDED AS DATED AMENDMENTS, NOT PROSE REWRITES. This file's convention
is to leave the original claim standing and rule against it, so the record shows what
was believed and what replaced it. Silently editing the stale sentence would destroy
the evidence that makes the correction checkable.

Applied to #62 #64 #131 #166 #179 #182 #237 #321 #329 #336. Representative:

  - #329 "Five MEFOR_ALLOW_INSECURE_TLS cells": the census is FOUR. #323 landed and
    routed transports/direct.py through the clamp; it now holds no call to the raw
    predicate at all (:63, :197, :215).
  - #321 "no test asserts the detectors can see a site code": false --
    tests/test_scan_forbidden.py has per-class hit tests for at least the site code
    (:126), a customer name (:83), a case-sensitive code (:91) and a routable IP
    (:107). The detector-coverage half of its Proposed 2 is already in the tree.
  - #62 plans a dual-read over "existing mfenc:v1 rows", but cell-bound mfenc:v2 is
    the default writer (settings.py:383 -> base.py:1841; crypto.py:36), and v2 folds
    (table, column, pk) into the GCM tag -- so a body landing under a different column
    must be RE-ENCRYPTED, not merely re-encoded. That tightens the catch.
  - #64's ordered plan still reads live ("Nothing builds before it"), but the
    measure-first phase completed 2026-07-12 (ADR 0051) and its step-2 lever is
    refused outright (ADR 0055 withdrawn; ADR 0107 "Do not build F2 or F3").

The refuters removed two overclaims before they landed: #62's draft asserted a live
store holds both mfenc markers (a fresh store under the shipped default holds only
v2 -- the defensible claim is that a MIGRATION must expect both), and #64's asserted
the multi-DB log split still remains, which could not be verified against ADR 0098 and
would have been a fresh false claim.

No item closes here: in every case the correction narrows the remainder rather than
discharging it, and the 2026-08-03 scores already price the remainder.

backlog_status_check.py --min-items 277: OK, 277 items, one status banner each.

* docs(backlog): file BACKLOG #1000 — prove each required merge context can fail

Escalated by the coordinator on the ground that it outlives the PR that fixed it.
Deliberately NOT filed as "fix the two-dot diff": that instance already landed in
39b62bf, and filing shipped work is the rot the hygiene gate exists to prevent.

The item is the CLASS. `.github/required-contexts.txt` names 13 contexts that block
merge, and not one of them is proven able to go red. The deliverable is a negative
control per context -- a fixture carrying the exact violation that context exists to
catch -- plus a CI job that fails when a required context has none, so the coverage
cannot silently decay as contexts are added.

Scoped narrower than "test the gates" on purpose: it does not re-test what each gate
checks, since the gates' own suites do that. It asserts one property per context --
this gate is capable of failing.

The argument is that the class has now fired at least four times here, each found by
hand and none by CI:

  #334  semgrep, required and blocking, scans a two-directory allow-list
  #327  six .gitignore rules are the sole control over maintainer-internal docs, and
        nothing asserts they still match anything
  #321  the forbidden-content gate exited 0 on a real site code and partner product
  #325  the same gate's home-path detector misses 1 of 4 spellings of a Windows path

Each is correctly filed as its own defect. None of them establishes the property that
would have caught all four before they shipped, and that property is a different
artifact from any of the individual fixes.

Value 7 / Difficulty 3, quick win, P1 -- not demand-gated; the trigger fired four
times. Ranked table and all four distribution lines recomputed to 93 open items; a
mechanical check confirms 93 banners and 93 rows agree on every triple.

Number allocated atomically via scripts/coord/alloc.ps1 (#1000 -- the first in the
post-partition public sequence, clamped to >= PUBLIC_BACKLOG_FLOOR), never grepped.

backlog_status_check.py --min-items 277: OK, 278 items across both files. The floor is
a floor, so growth passes it; it is there to catch shrinkage.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant