Skip to content

docs: repoint 73 backlog citations at the archive (BACKLOG #1095) - #279

Merged
wshallwshall merged 3 commits into
mainfrom
claude/backlog-1095-citation-sweep
Aug 7, 2026
Merged

docs: repoint 73 backlog citations at the archive (BACKLOG #1095)#279
wshallwshall merged 3 commits into
mainfrom
claude/backlog-1095-citation-sweep

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Authored by the session in worktree sweet-dhawan-57cbc8; pushed and opened by the coordinator at its
request. BACKLOG #1095 -- repoints citations that named docs/BACKLOG.md for items that actually live
in docs/archive/backlog/BACKLOG-CLOSED.md. Href-only; no prose rewritten. 34 files, +67/-67, all markdown.

Coordinator verification

I verified the queue-safety claims independently before pushing (not the author's anchor methodology,
which is their own and is described below):

claim verified
tip a02208d6 yes
base is 4d5f6304, not stacked on an unmerged PR head yes -- merge-base equals origin/main exactly
34 files, +67/-67, all markdown yes
docs/BACKLOG.md untouched yes -- 0 changed
docs/CONNECTIONS.md untouched yes -- 0 changed
docs/archive/backlog/BACKLOG-CLOSED.md untouched yes -- 0 changed

Those three exclusions are what make this safe to run alongside the open queue: it cannot collide with the
unarmed #1096 filing in #278, and it cannot fight merge train #274 over docs/CONNECTIONS.md (whose #273
fix is inside that train -- the author deliberately excluded that site rather than re-fixing it from
origin/main).

A docs-only PR is the blind mode here, not the cheap one -- the doc-drift guards live in pytest gated
on code == 'true', so they skip pre-merge and fire on the push to main afterwards. Run locally against
this exact SHA in a detached worktree: test_doc_ref_handle, test_crit2_inline_doc_drift,
test_docs_runbooks, test_docs_security_pathways, test_security_doc_drift,
test_asvs_file_surface_doc_drift, test_backlog_status_check -- 99 passed.

What the author deliberately LEFT, so nobody "finishes the job" wrongly

Author's verification, as reported

Applied by literal replacement on the named line, requiring the quoted string to occur exactly once there,
aborting rather than fuzzy-matching; dry run 73/73 clean before anything was written. All 35 anchor
fragments match exactly one real ## N. heading, with a known-bad fragment pushed through the same check
to prove it reports a miss. Coverage proved with a deliberately looser regex than the one that built
the work list: exactly one wrong-file site remains outside the change set, and it is the intended
CONNECTIONS.md exclusion.

Do not "fix" the doubled hyphens in the anchor fragments -- the slugger does not collapse spaces, so
they are correct.

Merge ordering

Auto-merge deliberately NOT armed. This branch's base already equals current main, so it is mergeable
immediately -- and that is exactly the problem: landing it now advances main and knocks merge train #274
(seven PRs, mid-CI) BEHIND, costing it a full ~35-minute cycle. It gets armed once the trains land.

The author's note on conflicts: their changed lines sit on the merge base, so if any of the 34 files does
conflict, a mechanical keep-both would restore the stale href. Take their side on those lines.

Retiring a backlog item moves it verbatim from docs/BACKLOG.md into
docs/archive/backlog/BACKLOG-CLOSED.md. Every citation that named the live file
keeps pointing at a file the item is no longer in. The link still resolves, so
nothing in CI can see it. #1094 fixed two such markers in CLAUDE.md section 12;
this is the same defect at repo scale.

73 citations across 34 files, href-only. No prose was rewritten. Visible labels
changed ONLY where leaving them would contradict the target -- a label reading
`BACKLOG.md` pointing at the archive -- and then only to `BACKLOG-CLOSED.md`.

THE TEST IS "DOES THE CITED FILE CONTAIN THE ITEM", NOT "IS THE ITEM CLOSED".
Those differ, and keying on closure would corrupt correct citations: #1073 is
closed and still legitimately in the live ledger. Item locations came from
parse_items imported from scripts/docs/backlog_status_check.py, per CLAUDE.md
section 11 -- never a hand-rolled scan of the banner alphabet.

DELIBERATELY NOT TOUCHED, each for a stated reason:

  Both ledger files -- ZERO edits to docs/BACKLOG.md and BACKLOG-CLOSED.md.
    Only two sites named them and both are excluded, so this change costs no
    conflict against the merge trains or the pending #1096 filing. The one real
    site (#322 at BACKLOG.md:2720) is left because the file is the most
    contended in the repo and the item number is visible in plain text a search
    away.

  docs/CONNECTIONS.md:2436 -- the #27 serial/ASTM row. Already fixed on a branch
    inside merge train #274. Sweeping it from origin/main would re-fix stale
    text and collide.

  QUOTATIONS OF THE DEFECT. docs/BACKLOG.md:6319, inside #1094, reads "Two of
    its markers CITED [`docs/BACKLOG.md`](BACKLOG.md) #26 and #27" -- past
    tense, describing rot that is already fixed. Repointing it would corrupt a
    historical record. A regex cannot tell this from a live pointer, which is
    the reason this was not done with sed.

  THE WRONG-NUMBER CLASS, which is a different defect and must not be swept into
    this one. ADR 0068:10 cites #11 and ADR 0113:9 cites #239; both numbers are
    absent from the live ledger, but the ARCHIVE's #11 ("`check` dry-run
    cross-products") and #239 ("Re-measure Steps view estate coverage") are
    unrelated to WebAuthn passkeys and to a Windows tray manager respectively.
    Repointing would convert a vague reference into a confidently wrong one that
    lands the reader on the wrong item. Left, and reported.

  MIXED-LOCATION LINKS, where one link covers items in both files so no single
    target is correct: docs/AI-OFF-MATRIX.md:50 (six items), docs/adr/0001:13
    (#1 archived, #3 live), THROUGHPUT-IMPROVEMENTS.md:215 (#62 live, so its
    link is already correct).

VERIFICATION
  - Plan applied by literal replacement on the named line only, requiring the
    quoted string to occur EXACTLY ONCE there; a mismatch aborts rather than
    fuzzy-matching. Dry run: 73/73 clean, 0 problems, before anything was
    written.
  - All 35 distinct anchor fragments introduced match exactly one real "## N."
    heading in the archive, checked after applying, with a known-bad fragment
    run through the same check to prove it can report a miss. Fragments were
    derived with a slugger that does NOT collapse consecutive spaces -- the
    doubled hyphens are correct, not typos.
  - All 74 archive hrefs in the changed files resolve to the archive from their
    own directory depth; the relative prefix differs by depth and was computed
    per file, not pattern-matched.
  - Coverage confirmed with a DELIBERATELY LOOSER regex than the one that built
    the work list: it finds exactly one wrong-file site outside this change set,
    docs/CONNECTIONS.md:2436, which is the intended exclusion.
  - backlog_status_check.py: OK, 365 items. No mixed line endings introduced.
    All 34 changed files are markdown; diff is 67 insertions / 67 deletions,
    line-for-line.
  - Staged by explicit path from the plan, cross-checked against git's modified
    set, so nothing another session is editing was swept in.

Not included: the broken-href class (13 sites, mostly (docs/BACKLOG.md) written
from inside docs/testing/master-test-plan/), the 12 line anchors past EOF, and
the 31 in-range anchors that drifted onto unrelated text. Those are separate
classes under #1095 and are catchable by a link checker, which this repo still
does not run.
Conflict was docs/PHI.md, and it is the semantic kind rather than the textual
kind: the two sides changed ADJACENT ROWS of one table, so git could not
auto-merge them although they are independent.

  main's side  (from #257, in merge train #274) rewrote ROW 10 -- the [alerts]
               webhook TLS escape now routes through the clamped
               weakened_tls_escape_permitted(posture) rather than the raw
               escape. That is a security-posture description.
  this branch  rewrote ROW 11 -- repointing [#323](BACKLOG.md) at
               archive/backlog/BACKLOG-CLOSED.md#323-... since #323 archived.

A mechanical keep-one-side loses one of them, and the two losses are not
equal. Taking this branch's block wholesale would REVERT main's row-10
security prose; taking main's block wholesale would restore the stale href
this branch exists to fix. Resolved by keeping BOTH: main's row 10 and this
branch's row 11.

The row-11 href was re-applied by literal single-occurrence replacement with
an assertion that it occurred exactly once, aborting rather than fuzzy-
matching -- the same discipline the sweep itself used.

VERIFIED AFTER RESOLUTION, in both directions:
  all 26 distinct archive hrefs this branch introduced are present
  main's row-10 clamp prose present (weakened_tls_escape_permitted(posture))
  stale [#323](BACKLOG.md) occurrences remaining: 0
  conflict markers remaining: 0
  docs/adr/0030 carries main's BACKLOG #331 amendment, byte-identical to main
  doc-drift guards: 93 passed

A caution on the verification itself, because it nearly produced a false
alarm: a first scan reported 4 of the 26 hrefs missing. They were present.
The scan wrapped its file reads in `except Exception: pass`, so the four ADR
files whose paths exceed the Windows limit under this deep temp worktree were
silently counted as misses -- an unreadable file was indistinguishable from a
missing href. Caught by grepping the four files directly. Print what you
scanned, and never let a bare except stand in for a negative result.
@wshallwshall
wshallwshall enabled auto-merge (squash) August 7, 2026 20:44
wshallwshall added a commit that referenced this pull request Aug 7, 2026
… (BACKLOG #1095) (#280)

Links throughout docs/testing/master-test-plan/ were written as if from the repo
root -- ](messagefoundry/pipeline/alerts.py), ](tests/), ](.github/workflows/ci.yml).
From a file three levels deep those resolve to
docs/testing/master-test-plan/messagefoundry/... and simply 404. The targets are
real; only the prefix was wrong. Each is rewritten to its true shortest relative
path.

This is the CATCHABLE half of #1095, and it is the half a link checker would
find -- which matters, because this repo runs no link checker at all. It is a
different defect from the citation rot in PR #279: there the link resolved and
the item behind it had moved; here the link never resolved.

333 hrefs across 14 files. 256 insertions / 256 deletions, line-for-line, all
under docs/testing/. No prose changed.

GUARDS, each protecting a specific way this could have done damage:
  - FENCED CODE SKIPPED. A path inside a ``` block is sample output being shown,
    not a link to follow. (Zero were found in fences here, but the guard ran.)
  - ONLY hrefs whose repo-root reading actually EXISTS were touched. Where the
    target is absent either way the link is broken for a different reason and
    was left, not papered over.
  - WITHHELD DIRECTORIES NEVER TOUCHED. The plan states at :653 that
    docs/security/, docs/reviews/ and docs/marketing/ are gitignored
    post-cutover and that a missing path there is expected, not a defect.
    Rewriting those would convert a deliberate publishing boundary into a
    apparent repair. Zero were matched, and the exclusion is in the script so a
    re-run stays safe.

LEFT ALONE, and named so nobody "finishes" it wrongly: 24 hrefs of the form
](messagefoundry/pipeline/alerts.py:27) -- the repo's file:line citation
convention embedded in a link target. The :27 suffix means they cannot resolve
as a path no matter what prefix is applied; fixing them means either dropping
the line number or adopting GitHub's #L27 anchor form, and that is a convention
decision, not a repair. 23 are in 15-alerting-and-observability.md.
One genuine miss remains: 08-publishing-and-promotion.md:43 cites
../VERSION-CONTROL.md, which does not exist anywhere in the tree.

A BUG IN MY OWN SCRIPT, caught and recorded because the failure was silent:
the first pass used href.lstrip("./") to strip a leading "./". lstrip takes a
CHARACTER SET, so ".github/workflows/ci.yml" lost its leading dot and became
"github/workflows/ci.yml", which does not exist -- so 28 real dotfile links were
misclassified as "target missing" and skipped. They looked identical to genuine
misses in the output. Caught only by reading the list of supposed misses and
noticing .gitignore among them. Fixed to strip one leading "./" and no more.

VERIFICATION
  - Broken links under docs/testing/ went 357 -> 24, and the 24 are enumerated
    above rather than summarised as a count.
  - A deliberately planted broken href was run through the same checker after
    the fix to prove it still reports a miss; a checker that has stopped seeing
    the class would also report zero.
  - No mixed line endings introduced; CRLF preserved.
  - Every changed file is under docs/testing/; staged by explicit path.

Cut from origin/main, NOT from the PR #279 branch: the two change sets touch
disjoint files and stacking would have widened an open PR.
@wshallwshall
wshallwshall merged commit fae2a8d into main Aug 7, 2026
33 checks passed
@wshallwshall
wshallwshall deleted the claude/backlog-1095-citation-sweep branch August 7, 2026 21:41
wshallwshall added a commit that referenced this pull request Aug 8, 2026
…nk-resolution gate (BACKLOG #1095) (#281)

* docs: fix the 270 links archiving broke, and gate the cause (BACKLOG #1095)

ROOT CAUSE, measured rather than inferred. Closing a backlog item moves its text
VERBATIM from docs/BACKLOG.md into docs/archive/backlog/BACKLOG-CLOSED.md -- two
directories deeper -- and nothing rewrites its relative links. A link written
"adr/0083-x.md" was correct while the item lived in docs/ and resolves to
docs/archive/backlog/adr/0083-x.md the moment it lands.

The proof is the distribution, not the story: 267 of the archive's 270 broken
links resolve CLEANLY when read from docs/. That is what pins the cause to the
move rather than to authoring error. The other 3 are ADR slug rot -- the ADR
merged under a different title than the one cited:
    0080-tls-syslog-forwarding.md            -> 0080-offbox-forwarding-tls-defaults.md
    0144-handler-config-taint-rules.md       -> 0144-security-lint-gate-over-admin-authored-router-handler-config.md
    0065-mount-the-web-console-in-process.md -> 0065-web-ops-dashboard.md
None was ever committed under the cited name, so those citations were written
from working titles that changed before the ADR merged. Repointed by ADR NUMBER,
and only where exactly one docs/adr/NNNN-* exists.

All 270 repaired. 208 insertions / 208 deletions in the archive, line-for-line.

THERE IS NOTHING TO FIX UPSTREAM. I looked for the archival tooling before
writing a guard: none exists. The move is manual, so #1094's remark that "the
archival pass generates the anchor" describes a generator that was never built.
A guard is therefore the only thing that can catch the next one.

THE GUARD
  scripts/docs/link_check.py -- resolves every relative markdown link. Repo-wide
  capable, takes an optional subtree. Prints WHAT IT SCANNED next to the verdict,
  because a green run over three files reads identically to a green run over the
  repo.

  Deliberately does NOT check: absolute URLs (reachability is a network question),
  fragments (heading slugs churn on every retitle), the withheld directories
  docs/security|reviews|marketing (gitignored post-cutover -- the master test plan
  states a missing path there is a publishing boundary, not a defect), fenced code
  (a path inside a fence is sample output), and file.py:27 citation targets (they
  cannot resolve whatever prefix is used; repairing them is a convention decision).
  A checker that overreaches is one people delete.

  tests/test_archive_link_resolution.py asserts the archive is clean, and:
    - refuses to pass on an empty scan (>= 1 file, >= 200 links), the same
      anti-narrowing floor backlog_status_check.py uses. Without it the test
      passes just as happily when a path change makes it read nothing.
    - PLANTS A BROKEN LINK in a throwaway repo and asserts the checker reports
      exactly it. A green result is evidence only if the checker can see the class.
    - pins the withheld-directory exemption.

SCOPE IS THE ARCHIVE ONLY, deliberately. Repo-wide the checker still reports 341
unresolved, because PRs #279 and #280 are not merged yet. A gate that is red on
arrival gets suppressed rather than fixed -- the same reasoning the ledger gate
records -- so this asserts only the subtree that is clean. Widening it is a
one-line change to _SUBTREE once the rest lands.

A BUG MY OWN TEST CAUGHT, recorded because it would have shipped silently: the
first version compared the RAW HREF against the withheld prefixes. A relative
link "../../security/X.md" shares no prefix with the repo-root form
"docs/security/", so the exemption matched nothing and would have flagged every
withheld link the moment the gate widened. Only the planted-case test found it.

VERIFICATION
  - Broken relative links in the archive: 270 -> 0, over 277 links checked.
  - A known-missing path run through the same checker afterwards to prove it
    still reports a miss.
  - ruff format, ruff check, mypy strict clean; bandit passes with the repo's
    standard nosec B603 B607 form on the three fixed-argv git calls.
  - The 7 doc-drift guards run by hand (99 passed) -- they are SKIPPED on a
    docs-only PR because .md is in the noncode allowlist and fire on the push to
    main afterwards, so a green PR check is not evidence there.
  - backlog_status_check.py: OK, 365 items, unchanged.
  - Cut from origin/main. Touches no file in PR #279 or #280, and neither of
    those touched the archive, so there is no conflict in either direction.

* fix(docs): widen the link gate to the repo, after removing three false positives

The gate was archive-only because a repo-wide assertion was red over pre-existing
breakage, and a gate red on arrival gets suppressed rather than fixed. This makes
it repo-wide. Three of the eight failures standing in the way were not defects.

Inline code. The checker already skips fenced blocks on the grounds that a path
inside them is being shown, not offered. A backtick span is the same argument at
smaller scale, and four sites turn on it: a regex whose character class contains
"](", two VS Code command: URIs -- one quoted in ADR 0110 as an attack payload --
and ADR 0160 quoting the very link it records as removed. Repointing any of them
would corrupt the text. Tested by POSITION, not shape, so the dominant idiom
[`x.md`](../x.md) keeps being checked; the test asserts both halves, because a
shape rule would stop checking most of the docs while staying green. Cost,
measured: 10 links of 5,344 no longer checked, five of them previously passing.

docs/releases/ is gitignored by ADR 0160 Phase 1, like docs/security/ and its
siblings. One archived throughput doc still cites the v0.1 plan that moved out.

.claude/ is the one to remember. It is gitignored but PRESENT in a long-lived
local checkout, so a repo-wide run passes there and fails on CI's clean clone. The
first measurement of this class was taken in such a checkout and undercounted it
by exactly 7 links; widening on that number would have put the gate red on arrival
-- the outcome the first paragraph exists to avoid. Read a gate result as a fact
about the configuration it ran in.

The exemption list is pinned as a set, so adding a prefix is a deliberate act with
a failing test attached rather than a silent widening of what goes unchecked.
Verified by planting a break in a real doc outside the archive and watching the
widened gate fail, then restoring: 5,327 links across 347 files, zero unresolved.

* docs: repair the last three unresolved links, and four dead line anchors

The three genuine breaks left repo-wide once the false positives were removed:

ADR slug rot in docs/BACKLOG.md:3073. Two ADRs merged under different titles than
the ones cited -- 0094 as granular-expiry-only-tls-relaxation, 0118 as
secure-by-default-security-configuration-section. The number is the identity, so
these repoint with confidence; the same class as the three the archive pass found.

One survivor of the 333-href repair (#280) in the same subtree:
08-publishing-and-promotion.md cited ../VERSION-CONTROL.md where the file is two
levels up, not one.

Four BACKLOG.md:<line> anchors in the archive become item references. They are
here rather than on the docs-only branch because this PR already edits this file
within three lines of them, and adjacent hunks conflict. Root cause of the whole
anchor class: commit 4ea1501 (the master test plan) is NOT a descendant of
03f1fbd (the 185-item archival) -- parallel branches, so anchors written against
an 8,742-line ledger landed beside a commit that cut it to 3,858. Each of the four
was recovered from its citing prose and adversarially re-verified against the item
it names; the line numbers were dead, the item numbers survive archival, which is
the point.

* ci(docs): run the link gate on docs-only PRs, where the links actually break

Widening the gate to the repo left a hole I put there. `pytest` is gated on
`code == 'true'`, so on a docs-only PR the new repo-wide assertion did not run at
all -- it would have fired on the push to main afterwards, after the merge. The
PRs most likely to break a relative markdown link are exactly the ones that were
skipping the check.

ci.yml already documents this class and keeps an ungated list for it, citing four
docs-only PRs that merged on 2026-08-04 with citation errors no guard caught --
one of them about inbound citations of `docs/releases/`, which is the same
directory this gate now treats as withheld. The gate belongs on that list; it is
added to both the printed manifest and the pytest invocation, so the "print what
you scanned" property is preserved.

Verified by running the step's exact command line: 184 passed, 89 skipped.

Also renames test_archive_link_resolution.py -> test_link_resolution.py. The old
name was accurate when the scope was `docs/archive/backlog` and became a false
claim the moment the assertion went repo-wide -- a file whose name understates
what it enforces is the same defect as a commit message that understates its own
change. Nothing outside the file referenced it by name.

* fix(docs): resolve links against the tracked set only, never the filesystem

The gate now gives the same answer in a long-lived local checkout and on CI's
clean clone BY CONSTRUCTION, rather than by enumerating the paths where the two
differ. For a repo-wide invariant that is the difference between a control and a
coin flip, and it is the property the PR body was about to claim on the strength
of an exemption list that did not provide it.

The old resolver accepted `target in tracked OR (root/target).exists()`. That
second arm passes anything merely PRESENT, and gitignored-but-present paths are
exactly the set a developer has and CI does not. It is the mechanism of the
.claude/ finding, not a defence against it: 7 docs link to .claude/settings.json,
which passed here and would have failed on the runner, and the first repo-wide
measurement undercounted by exactly those 7.

Dropping the fallback outright would have been wrong, and measuring said so before
I proposed it: `git ls-files` lists files and NEVER directories, so 122 legitimate
directory links (docs/adr, environments, .github/workflows) resolve only via the
filesystem arm. So tracked_paths() derives every ancestor prefix of every tracked
file and resolves against files plus derived directories. Repo-wide result is
unchanged at 5,327 links, zero unresolved.

Two tests pin the pair, and the first is demonstrated rather than argued: a file
PRESENT ON DISK but untracked must fail (verified by re-running the old resolver
against the same planted repo -- it reports 0 failures where the new one reports
1), and a directory containing a tracked file must resolve.

.claude/ stays in WITHHELD as POLICY, not as protection, and its comment now says
so. It was added as protection; keeping that framing after the structural fix
would be a compensating control resting on a premise that is no longer why it
holds (SDS-3.7). Removing the entry would now make those 7 links fail honestly and
identically in every checkout.
wshallwshall added a commit that referenced this pull request Aug 11, 2026
…umped (BACKLOG #122)

The third gate this branch left red, and the same shape as the two PHI-inventory
ones: the rescue commits added the additive `log_sinks` field to SystemStatus --
a DTO the web console renders -- without bumping the seam on either side or
refreshing the golden. test_webconsole_seam_snapshot_matches_golden caught it,
deterministically, and printed the exact remediation.

Bumped ENGINE_UI_SEAM 18 -> 19 and messagefoundry_webconsole.SUPPORTED_ENGINE_SEAMS
to match (the console accepts exactly the engine's seam, BACKLOG #279), and
regenerated the golden -- a two-line diff, the version and the SystemStatus field
list, nothing else moved.

The bump is required even though the field is purely additive with a default: the
golden introspects SystemStatus's field set, so the handshake trips on any added
field. That is deliberate -- this gate is the sole backstop against a future
engine's unbumped render-breaking DTO rename, so it must stay comprehensive.

Verified: the 3 seam tests pass, and packaging/messagefoundry-webconsole/tests runs
350 passed / 9 skipped with the new seam pinned on both sides.
wshallwshall added a commit that referenced this pull request Aug 12, 2026
…surface (BACKLOG #1220) (#346)

* feat(seam): discover the webconsole seam surface instead of enumerating it (BACKLOG #1220)

Layer 1 of #1220, and it is INERT: nothing consumes this yet. The generator still reads its
five hand-maintained tuples and ENGINE_UI_SEAM is still the hand-chosen integer 20. Separating
coverage from derivation means a bisect can tell which one broke something.

WHY THIS AND NOT A HASH OVER THE EXISTING LISTS. #1220's ruling is that the hash input must be
DISCOVERED, never enumerated, because a content hash over a hand-maintained list stops colliding
while still failing to move when an unlisted model changes -- and a hash LOOKS like it covers
content, so the next reader trusts it more than the list it replaced. Measured at ebf4882, three
of the five tuples had already drifted:

  _API_MODELS_DTOS      30 curated, 55 reachable. 7 DTOs the console imports directly are absent
                        (UploadedFileList among them); the other 18 are reachable only by NESTING,
                        and _dto_fields records field names ONE level deep with no recursion, so a
                        nested model's field set is absent from the contract entirely.
  _API_SECURITY_SYMBOLS missing client_ip and enforce_phi_read_pacing, carrying five names the
                        console no longer imports. That is why _ui_seam.py asserts the console
                        imports six symbols directly -- false for five of six.
  _AUTH_SERVICE_METHODS AuthService.has_action_step_up is called at _auth.py:620 and absent. Six of
                        the seven additions are PROPERTIES, on which inspect.signature raises, so
                        the curated list held methods only because the renderer could not do more --
                        the instrument's limitation had silently defined what counted as contract.

The proof the defect is real rather than theoretical is in the history: 40a4d5d added a REQUIRED
scope field to UploadedFileList, which pages/uploaded_logs.py renders unconditionally, and touched
no seam file across 17 changed files. The seam moved later, by hand, in review.

CALIBRATION. _APP_STATE_ATTRS was the one tuple with zero drift in either direction, and the
two-sided rule (console reads intersected with engine writes, plus console writes) reproduces its
eight names EXACTLY. A walk that merely returned "more" would be consistent with measuring the
wrong thing; reproducing the un-drifted list is the evidence that it measures the contract.

The AuthService rule is annotation-driven -- attribute access on a receiver annotated AuthService or
assigned from get_auth() -- rather than a bare name intersection. The bare rule yields 38 names, ~11
of which are present only because they collide with AdminHandlers field names, and a gate that moves
for methods the console never calls is one people stop reading. Annotation-driven yields 34: the 27
curated plus 7 verified real, nothing dropped.

FAIL LOUD, never skip. A star import, an alias escaping attribute position, a dynamic getattr on a
DTO module, or an unresolvable model raises SeamDiscoveryError. A silent skip would recreate the
enumeration blind spot inside the walk's control flow, which is strictly worse than the tuple it
replaces -- a 30-line tuple was at least reviewable. Every loud case measures zero occurrences
today, so the guard costs nothing now and its first firing is a genuine new idiom.

The closure deliberately crosses module boundaries: a rendered DTO that exposes a model defined
elsewhere still ships that model's field set across the seam. Discovery runs in the generator and
the test only -- messagefoundry/ must not import the console or scripts/, and a seam computed at
import time would make every proof condition pass vacuously.

Verified: ruff check + format clean, mypy strict clean, 21 new tests plus the 3 existing seam tests
pass. Full-suite baseline at this merge-base was 11882 passed / 861 skipped / 0 failed.
NOT verified: no CI leg run; the SQL Server and Postgres legs skip locally.

* feat(seam): feed discovery into the snapshot and delete the curated tuples (BACKLOG #1220)

Layer 2 of #1220. The generator now reads the DISCOVERED surface; the five hand-maintained tuples
are gone. ENGINE_UI_SEAM is STILL the hand-chosen 20 -- deriving the value is layer 3. Isolating
coverage from derivation keeps a bisect able to say which one broke something.

WHAT THE GOLDEN GAINS. It went from 196 lines to 237, and the additions are the measured blind spot:

  UploadedFileList        appeared ZERO times in the old golden. It now carries its field set AND
                          its Literal values. This is the DTO whose required `scope` field landed in
                          40a4d5d without moving any seam file -- the gate stayed green through
                          exactly the change it exists to catch.
  25 more DTOs            7 the console imports directly, 18 reachable only by nesting. The closure
                          matters because _dto_fields records names one level deep with no
                          recursion, so a nested model's fields were absent entirely.
  6 AuthService properties action_step_up_required, ad_enabled, kerberos_available, oidc_enabled,
                          oidc_flow_ttl_seconds, store -- all read by the console across the seam,
                          none recorded. inspect.signature RAISES on a property, which is why the
                          retired tuple held methods only: the renderer's capability had silently
                          defined what counted as the contract. _member() renders them now.
  has_action_step_up      called at _auth.py:620, absent from the golden, and on the ADR 0077
                          per-action step-up path.
  client_ip,              imported by the console and never hashed. The same correction drops five
  enforce_phi_read_pacing stale names the console no longer imports -- which is why _ui_seam.py
                          asserted the console imports six api.security symbols directly, false for
                          five of six. That assertion is deleted with the tuple it described.
  enum + Literal sets     Field NAMES are not the contract. pages/uploaded_logs.py renders
                          UploadedFileList.scope as _SCOPE_NOTES[data.scope], so renaming a literal
                          from "own" to "owner" would KeyError at runtime while a field-name-only
                          snapshot stayed byte-identical.

NO SEAM BUMP, DELIBERATELY. The snapshot changed because the GENERATOR changed, not because the
engine's contract did. A console built against seam 20 is no less compatible than it was an hour
ago -- recording more of an unchanged contract cannot make an older console incompatible. Bumping
here would spend a hard console-wheel refusal (SUPPORTED_ENGINE_SEAMS holds exactly one value,
BACKLOG #279) on a bookkeeping change.

The calibration tests now assert against the retired tuples FROZEN AS LITERALS rather than reading
them from the generator. Reading them would make the calibration vacuous the moment the generator
stopped carrying them, which is this commit. Frozen, they keep proving the two properties that
matter: app.state is reproduced EXACTLY (the one tuple with zero drift), and no previously-curated
name is ever dropped.

Verified: ruff check + format clean, mypy strict clean on both scripts, 24 seam tests pass, plus
tests/test_ui_oidc_interstitial_route.py and the full packaging/messagefoundry-webconsole suite --
those are the only three test files in the tree that reference the seam, the golden, or the
generator (grep over tests/ and packaging/.../tests/ for ENGINE_UI_SEAM|webconsole_seam|
SUPPORTED_ENGINE_SEAMS|engine_seam).
NOT verified: no CI leg run; full suite re-run deferred to the end of the layer stack.

* feat(seam): derive ENGINE_UI_SEAM from the contract surface (BACKLOG #1220)

Layer 3, and the one that closes #1220. ENGINE_UI_SEAM is no longer a number anyone picks: it is a
16-hex SHA-256 of the DISCOVERED contract surface, produced by
`python scripts/webconsole_seam_snapshot.py --write`. Value: 494a51230dce5730.

WHY THIS FIXES THE COLLISION AND GATING THE INTEGER WOULD NOT. Two branches bumping to 19 for two
independent contract changes produced a COSMETIC conflict in a comment block while the golden
snapshot auto-merged CLEAN carrying both changes under one seam -- so resolving the visible conflict
correctly still shipped the fault. A digest has no such failure mode: two branches changing different
surfaces derive different values, and their MERGED surface derives a THIRD matching neither, so the
merge reds. Uniqueness stops being something a human maintains.

str, NOT a truncated int, and the old skew test is why. It asserted `ENGINE_UI_SEAM - 1` is refused,
under a docstring claiming "one seam older AND one newer". Under an int digest that arithmetic still
evaluates, the assertion still PASSES, and the docstring silently becomes false -- a passing test
making a claim the value no longer supports, which is the same defect class #1220 was filed against.
A str makes it a TypeError instead. It also removes the hand-typable affordance: someone can write
21 and it looks legitimate; nobody types a hex digest by hand and believes it.

ANTI-CIRCULARITY IS BY CONSTRUCTION. contract_sections() never reads ENGINE_UI_SEAM, so the seam
cannot feed its own digest -- there is no "strip the seam line" filter that could quietly stop
matching. The test asserts it by MOVING the constant and requiring the digest to hold still, not by
checking the seam's text is absent from the input: a substring check is defeated by a short or
coincidental value (the header already contains the literal 0065). Mutation-proved before trusting
it -- a generator that folds the seam into its own input makes that test FAIL, as it must.

Four tests, three of them new: the stored value equals the derived digest (the gate); the digest
holds still when the seam moves (anti-circularity); the digest MOVES when a rendered DTO gains a
field, with 40a4d5d as the historical control where it did not; and the skew test rewritten to
refuse the empty string, a truncation, an extension and an arbitrary digest, preserving the
both-directions intent BACKLOG #279 put in the original.

The failure message is rewritten and is now pure ASCII. The old one carried U+2014 (a replacement
character on a cp1252 console, the developer-facing half of #1221) and told the reader to "bump
ENGINE_UI_SEAM" -- an action that no longer exists. A gate whose message names the wrong repair
teaches the wrong repair.

--write rewrites the constant and the golden, and REFUSES rather than guesses if the constant line
does not match exactly once. It deliberately does NOT write the console's SUPPORTED_ENGINE_SEAMS:
that is the independent half of a two-wheel handshake, and a tool writing both sides turns it into a
self-consistent tautology. The message spells out the one-line manual edit instead.

Also fixes tests/test_ui_oidc_interstitial_route.py, which passed engine_seam=0 into a field now
typed str. Nothing automated would have caught it -- tests/ is outside mypy's configured scope and a
dataclass does not validate at runtime -- so it was found by grepping every consumer deliberately.

Verified: ruff check + format clean; mypy strict clean on messagefoundry_webconsole; 40 tests pass
across the three seam-touching test files. mypy on messagefoundry reports 21 errors in 4 files
(auth/webauthn.py, parsing/dicom/_deps.py, parsing/fhir/_deps.py, transports/dicom.py) -- all
pre-existing and all downstream of four optional extras absent from this local venv (pydicom,
pynetdicom, webauthn, fhir), which CI installs. None of the four is in this diff and none references
the seam.
NOT verified in this commit: the two-branch merge proof and the full suite; both run next.

* fix(seam): refuse an unresolved ForwardRef instead of walking past it (BACKLOG #1220)

Found by running #1220's own acceptance proof, which is the entire reason for running it rather than
reasoning about it. Proof (c2) plants a DTO reachable ONLY as a field of one the console imports,
renames that nested DTO's field, and requires the gate to red. It stayed GREEN.

MECHANISM. Pydantic resolves string annotations at class-build time, but NOT when the referenced
class is defined later in the module and nobody calls model_rebuild(). The field's annotation then
stays a ForwardRef; typing.get_args() returns () on it; and the closure walked straight past a nested
DTO whose field set belongs in the contract. Measured directly:

    SystemStatus.model_fields['proof'].annotation
      -> ForwardRef("'ProofNested | None'", is_class=True)
    typing.get_args(...) -> []

That is a SILENT SKIP inside the walk -- precisely what this module's own docstring forbids, and the
defect it was written to remove, reproduced one level down. A skip here is indistinguishable from
full coverage at every downstream vantage point: the digest is computed, the gate is green, and the
uncovered model looks exactly like a covered one.

The fix raises SeamDiscoveryError naming the class and the ref, and says how to resolve it (define
the referenced model first, or call model_rebuild). Zero occurrences in the tree today, so this costs
nothing now; its first firing is a real hole.

With the guard in place the corrected proof (c2) discriminates as it should -- renaming the nested
field reds the gate on this tree and leaves origin/main GREEN, which is the defect the closure exists
to close. Same edit, opposite outcomes.

Verified: ruff check + format clean, mypy strict clean, 28 tests in tests/test_seam_discovery.py
pass including a new one that drives the ForwardRef case directly.

* docs(seam): record the seam digest in the crypto inventory (BACKLOG #1220)

The full suite caught this, not review: three tests failed on the #1220 stack, all one cause --
scripts/webconsole_seam_snapshot.py now calls hashlib.sha256 and the crypto-inventory gate refuses an
undocumented crypto call site. The gate is correct and this is the entry it asked for.

Recorded in all three places the convention requires, because they are three INDEPENDENT
enumerations of the same fact and a fix that updates one leaves the other two lying:

  scripts/security/crypto_inventory_check.py  INVENTORY -- what the gate enforces
  tests/test_security_static.py               _CRYPTO_SITES_OUTSIDE_THE_PACKAGE -- the test-side pin
                                              that covers roots the gate does not yet walk (#282)
  docs/ASVS-L2-PHASE0-CHANGES.md section 4    the human inventory the gate calls itself the
                                              machine-readable companion to

WHAT THE RATIONALE HAS TO SAY, because "we hash something" is not a justification: it is a CHANGE
DETECTOR, not a security control. No secret, no key, no message authentication, and nothing user- or
PHI-derived is hashed -- the input is a serialization of public type signatures, field names, enum
members and Literal values. The property required is accidental-collision avoidance across the
contract surfaces this project will ever produce, which at 64 bits is 2.7e-12 for 10,000 distinct
surfaces (about 500x the ~20 seam moves to date). Preimage resistance buys nothing: anyone able to
craft a colliding surface already has commit access to _ui_seam.py, where writing the constant
directly is strictly easier. SHA-256 rather than BLAKE2 or a truncated non-approved digest only
because the engine renders a fips_mode attestation, and a non-approved hash in the shipped surface
invites a FIPS question for zero benefit.

Verified: tests/test_security_static.py + tests/test_crypto_inventory_scanner.py, 64 passed, 0
failed -- the three that failed on the previous full run. ruff check + format clean.

* feat(ai): refuse an unserviceable [ai].provider at config time (BACKLOG #95)

Validator ONLY, per the owner's ruling -- no per-provider wire shapes.

THE DEMAND GATE ON #95 WAS LIFTED BY THE OWNER IN SESSION on 2026-08-12, in response to a
pause-and-ask that named the item, its scope and its cost. Recording that here because the ledger row
cannot cite a source otherwise, and this project has already resolved one lane-vs-lane dispute over
an unsourced claim that a gate had been lifted.

WHAT WAS WRONG. settings.py declared `provider: str = "claude"` with no validation, so any string
loaded clean -- azure_openai, gpt-4o-gateway, '', even '../../etc/passwd'. Verified through the real
config path (load_settings), not just the constructor.

WHY IT MATTERS EVEN WHEN THE BROKER IS NEVER BUILT, which is the part that decided the design: the
value is recorded in the PER-USE AUDIT regardless of mode (api/app.py). A config naming a provider
the engine cannot service therefore made the audit trail assert something untrue -- so the validator
is field-level and refuses in every mode, rather than a model_validator gated on managed_endpoint.
The narrower form was the defensible alternative; this is the deliberate choice, not the default.

THE ALLOWLIST IS ONE ENTRY, AND ITS SOURCE OF TRUTH IS A HARD-CODED REQUEST BODY. AiBroker.chat
builds one wire shape unconditionally -- an Anthropic Messages body with x-api-key and
anthropic-version, and an _extract_text that assumes Anthropic's content-block list. There is no
provider registry, no dispatch, and AiBroker.provider has ZERO readers, so nothing can derive the
serviceable set. That makes the list hand-maintained, which is a real cost and is written down as
one. A test pins it to chat()'s body so widening the list without teaching chat() a second shape
fails loudly.

THE INVERSE FAILURE IS THE TEMPTING ONE. Listing aspirational names (azure_openai, bedrock, ollama)
would ACCEPT configurations the broker still cannot service, turning a clean config-time refusal back
into the opaque runtime failure this item exists to remove. The list describes what chat() can send,
never what the roadmap intends.

DOCS. The item names docs/AI.md:22. That line is stale, but docs/CONFIGURATION.md:637 carried a
STRONGER false claim the item does not mention -- that provider selects "the broker's request shape",
which is not true in the code and directly contradicted AI.md. Fixing only the named line would have
left the more actively misleading one in place. Also corrected: AI.md asserted "No model-provider or
engine broker integration exists yet" while managed_endpoint has been BUILT since ADR 0135, and the
mode tables omitted managed_endpoint entirely (the string appears zero times in that file). The
ai_broker module docstring said the Anthropic shape "is used when the provider is claude", implying a
branch that does not exist.

Verified: ruff check + format clean, mypy strict clean on both changed modules, 94 tests in
tests/test_ai_policy.py + tests/test_ai_broker.py pass, and 916 passed / 0 failed across every
doc-drift, link and config test in the tree.
NOT verified: no CI leg run.

* fix(uploads): attribute the upload.prune audit row to the system (BACKLOG #1224)

Both sites, because closing one leaves the same false attribution reachable by the other path: the
save-time opportunistic sweep in the upload handler, and the background UploadRetentionRunner's audit
closure in the API lifespan. Located by the literal "upload.prune", not by line number -- the item's
own cited lines had already moved 87 and 121 lines when #1152 landed above them.

THE DEFECT IS CROSS-OWNER, AND IT IS STRUCTURAL RATHER THAN INCIDENTAL. prune_expired() is
deliberately UNSCOPED -- it has to be, since the per-uploader quota and the sweep both need to see
every file -- so the operator whose upload triggers a pass is in general NOT the owner of what it
prunes. The row named the pruned file's OWNER as actor while stamping the TRIGGERING operator's
address as client, asserting that X deleted their own file from Y's host. That is true of neither of
them.

The fix applies a decision this repo already made rather than inventing one. actor="system" matches
pipeline/retention.py's retention_purge (plus dr, dr_backup, reference_sync and the auth bootstrap).
Dropping client is not tidying: _record_reload_audit's own contract is that client is the address OF
THE ACTOR NAMED IN THE ROW, and once the actor is the system principal no address is in scope --
ADR 0150 decision 4 rejects exactly this pairing for dual-control config reload, calling a stamped
approver address "worse than NULL". Note the engine-internal-writes rule does NOT justify it here,
because site 1 genuinely runs inside a request; the actor-address correspondence is what does.

The uploader is not lost -- it stays in detail.uploader, which is where a reader can still see whose
file went without the row claiming they went and deleted it.

RED-FIRST, TWICE, because one mutant was not enough. Reverting the actor reds the test with
actor == 'op' while op2 triggered the sweep -- the cross-owner defect in one assertion. And a
PARTIAL-FIX mutant (actor corrected, client=client_ip(request) left behind) reds on
`assert not '127.0.0.1'`. That second mutant is the point: a test asserting only the actor string
would have passed the half-fix, which is the likeliest way this gets "fixed" wrong.

Zero tests referenced "upload.prune" before this one, so the defect had no guard at all.

Verified: 97 tests across tests/test_upload_api.py, tests/test_uploads.py and
tests/test_audit_integrity.py pass -- the last because dropping client changes the ADR 0150
hash-chain payload shape for these rows. ruff check + format clean, mypy strict clean on app.py.
NOT verified: no CI leg; SQL Server and Postgres record_audit implementations were read, not executed
-- the audit-integrity suite covers the SQLite path only in a local run.

* fix(search): key saved presets on the immutable user_id, not the username (BACKLOG #1225)

Four sites in api/app.py, and the WRITE went first because it is the setter the other three read --
re-keying only the readers would have made every newly created preset invisible to its own creator.
Located by store-method name, not line number: the item's cited lines had all moved +122 when #1152
landed above them.

THE ENABLING HALF THE ITEM DOES NOT CITE. A username being reassignable is only half the defect. The
other half is that delete_user (store/store.py) removes user_roles, sessions and webauthn_credentials
and NEVER preset rows -- so a departed operator's presets outlive the account under a name that is
now free to reissue, and a recreated account of that name inherited them, including the PHI-shaped
`criteria` that the layered compose returns. Both halves are asserted in the new test.

WHY THE EXISTING SUITE WAS NOT EVIDENCE. test_preset_crud_and_owner_scoping compares two operators
who are BOTH LIVE, and it passes on the defective code. The defect needs an account to go away and
its name to come back; nothing exercised that. The new test does, through the list route, the delete
route and the layered compose.

RED-FIRST, AND THE FIRST RED WAS THE WRONG ONE. Restoring username-keying at all four sites initially
failed on my own PRECONDITION -- which queried by user_id and so could not survive a username-keyed
build -- masking the assertion that matters. Made key-agnostic, the mutant now reds exactly where it
should: the recycled account sees {'name': 'mine'} where it must see []. A test that goes red for the
wrong reason proves nothing, and it looks identical to one that does not.

I ALSO TRIED TO CLEAN UP THE STRANDED ROWS AND BACKED IT OUT. The re-key leaves pre-existing rows
matching no user_id: unreachable by every route, with their encrypted criteria lingering. I added a
one-shot DELETE keyed on `owner NOT IN (SELECT id FROM users)` -- exact, idempotent, and it would have
collected delete_user's orphans too. tests/test_retention.py caught it:
test_reopening_a_pre_306_db_migrates_last_used_at_in asserts that migration PRESERVES rows, and my
statement ran on every open and destroyed data. That is a behaviour change well outside this item, so
it is reverted. CONSEQUENCE, stated rather than hidden: a developer with an existing dev store will
find their old presets unreachable and should recreate the store. There is no correct
username-to-user_id backfill -- a freed name may since have been reissued, which IS the defect.

THIS IS A NARROWING, NOT A CLOSURE, and the ADR now says so: _upsert_ad_user re-binds a surviving
mirror row's user_id when a directory-side sAMAccountName is recycled without a MessageFoundry
delete_user, so a user_id-keyed check is still defeated on that path. BACKLOG #1143 is the real close.

Also corrected: the schema comment that read "the owning username" and would have become a lie, the
field_authz seed that wrote presets under a username (a row no route can now reach), and ADR 0136,
which asserted `owner = identity.username` as the design.

NOT swept: identity.username appears 60 times in app.py and 46 are audit actor= fields, where
recording a NAME is correct. Only the four ACCESS KEYS moved.

Verified: ruff check + format clean, mypy strict clean on both changed modules; 212 passed across
every preset/retention/field-authz/schema/PHI-inventory/doc-drift selection, and 447 passed across
every doc, ADR and link test.
NOT verified: no CI leg. The Postgres and SQL Server preset paths were read, not executed -- they
skip locally, and per project experience a blind DB-leg change fails only in CI. Their DDL is
unchanged by this commit (the column still holds a string) but their tests seed owners by name.

* fix(lint): harden the ASVS tally gate's own stdout against a cp1252 console (BACKLOG #1221)

The gate prints DOCUMENT CONTENT, and the corpus it scans is documentation -- so a hit whose line
carried a glyph killed the run with UnicodeEncodeError before the finding could be read.

THE DEFECT IS INVISIBLE UNTIL THE GATE HAS SOMETHING TO SAY. Against the real corpus it exits 0 and
looks perfectly healthy; it fails exactly when it is needed. Reproduced deterministically here before
fixing: a one-line doc with a new tally and a U+2192 dies at the findings loop, and the same run is
green on the shipped corpus.

STREAM-LEVEL, NOT A TARGETED SAFE-PRINT AT THE OBVIOUS SITE -- and that is a correctness choice, not
a style one. Python's \d is Unicode-aware, so _AGAINST_TOTAL and _ARITHMETIC take m.group(0) VERBATIM
into the token: idioms_for_line() over Arabic-Indic digits yields a token carrying non-ASCII. That
token flows into the --print-keys output, the stale-baseline report AND the baseline file, so
hardening only the findings loop leaves the same class open in three other places. The two fix shapes
are not equally defensible, which is what the item asked to have decided explicitly.

Copied rather than imported: this file is on MIRRORED_TOOLS and is contractually stdlib-only, so it
cannot reach for a shared helper. The guard mirrors messagefoundry/__main__.py's shape including its
reason for being guarded -- some stream wrappers lack reconfigure or reject it, and hardening must
never itself crash the tool.

THE TEST IS A SUBPROCESS, DELIBERATELY. The existing 45 tests never exercised the print path at all.
Forcing the encoding in-process would NOT be the same instrument: pytest's capture wrapper is one of
the very objects the hardening guards against, so an in-process test can pass having measured
nothing. The child runs under PYTHONIOENCODING=cp1252 with no error handler, so it starts at
errors='strict' and only the fix can save it -- a test of the fix rather than of the environment.

Mutation-proved: removing the _harden_stdout() call reds the new test with the child's own
UnicodeEncodeError traceback in the failure message.

Verified: 45 tests in tests/test_asvs_tally_lint.py plus the vault mirror contract pass; ruff check +
format clean. The real-corpus run still exits 0.
NOT verified: no CI leg. The lint's CI job runs on ubuntu (utf-8), which is why the test forces the
encoding rather than relying on the platform -- the defect is not reproducible there by default.

* test(seam): classify the seam digest as non-key material (BACKLOG #1220)

The full suite caught this, and it is the SECOND gate to fire on the same commit -- the crypto
inventory wanted the call site recorded, and this one wants every ASVS section 4 row classified as
key material or explicitly not. Both are correct, and between them they are why "we hash something"
cannot be waved through here.

Classified as NOT key material, with the reason the gate asks for: it is a keyless content hash over
PUBLIC type signatures, field names, enum members and Literal values -- a change detector for the
engine/console handshake, not a key, a secret, or a message authenticator. Nothing user- or
PHI-derived is hashed, and the digest is committed in source on both sides of the seam, so there is
no scope to state and nothing to rotate.

That places it with the Config fingerprint, the ASVS corpus pin and the engine wheel attestation --
the three existing rows of exactly this shape.

Verified: 69 tests across tests/test_key_usage_scope_inventory.py, tests/test_security_static.py and
tests/test_crypto_inventory_scanner.py pass. ruff clean. This was the ONLY failure in the full-suite
run over the whole lane (1 failed, 11914 passed, 861 skipped).

* feat(uploads): record the immutable owner key on the prune audit row (BACKLOG #1224)

Owner-approved follow-on to #1224, raised rather than folded in silently because it is beyond that
item's stated scope -- #1224 was about the ACTOR field, this is the DETAIL.

WHY IT IS WORTH THE KEYSTROKES. The prune row is a permanent record of a deletion whose subject
cannot be recovered afterwards: the file is gone, so `detail` is the only surviving description of
whose it was. It recorded `uploader`, a username, and a username is reassignable -- which is the
entire finding of #1225, shipped two commits ago. A row read a year from now could name a different
person than it meant, and nothing would flag it.

UploadedFileMeta already carries both fields and its docstring says the split is deliberate
(uploads.py:116: "uploader_id is the account's IMMUTABLE..."), so the data was in hand at both sites.
This records both: the username because that is what a human reads, the id because that is what still
identifies the account after the name moves.

FIRST-OF-KIND, AND SAID OUT LOUD. No other audit row in api/app.py carries an id in its detail, so
this sets a convention rather than following one. That was the honest argument against doing it, and
the counter-argument that won: you fix things where you touch them, and the alternative -- an item
proposing the convention for every owner-bearing audit row -- improves nothing today and may sit.
If that broader sweep is ever wanted, these two rows are the precedent to point at.

No schema change: `detail` is a free-form JSON string, so existing rows still verify against the
ADR 0150 hash chain. Ran the audit-integrity suite rather than assuming that.

The test asserts uploader_id is present AND that it differs from the username, so a fix that
populated it with the name would still red.

Verified: 68 tests across tests/test_upload_api.py and tests/test_audit_integrity.py pass; ruff check
+ format clean; mypy strict clean.

* backlog: close BACKLOG #1220 -- the derived seam, and the numbers this item published are now void

The fix is the building lane's (claude/instruments-config-handoff-e64adc). This commit
adds only the banner, which is the coordinator's to write: allocation and the commit
that files it cannot be split across worktrees, and the claim gate requires the banner
in the SAME PR as code citing BACKLOG #N. I will not buy that check's green by removing
the citation from the PR body -- a green there is evidence nothing looked.

The banner records three things beyond "shipped":

1. It shipped the option THIS ITEM PREFERRED. The Scope says derive the seam from a hash
   so collisions are impossible by construction, and warns the gate-an-integer
   alternative is "one more thing to remember". The built form is the derived one, and
   the surface is DISCOVERED rather than enumerated, which was the stated condition.

2. THE ITEM'S OWN NUMBERS ARE VOID, kept rather than deleted. It publishes
   origin/main = 18 and "the second branch to land re-bumps to 20". Measured 2026-08-12:
   main reached 20 when #1152 landed, so BOTH held branches sat at 19 -- BELOW main --
   and that rule had already inverted before anyone applied it.

3. WHAT IT BREAKS. The seam becomes a str digest, so the two held branches no longer
   carry a stale integer, they carry a mypy-strict TYPE error. Their bumps are a rewrite,
   not a renumber. Sequence #1227's class fix after this, never concurrently.

Attribution is split in the banner: what the lane reported (the three acceptance
criteria, the 25-DTO coverage figure) is labelled as reported, and what was measured
here (main at 20, both branches at 19) is labelled as measured.
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