Skip to content

Bind source entities to masters deterministically in the shared crate - #288

Merged
lamemustafa merged 44 commits into
masterfrom
claude/recursing-mirzakhani-94af40
Sep 12, 2026
Merged

lamemustafa merged 44 commits into
masterfrom
claude/recursing-mirzakhani-94af40

Conversation

@lamemustafa

@lamemustafa lamemustafa commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Outcome and reason

Given the entity names in a source document and one company's observed masters, Bridge now produces bindings, near-miss candidates and an explicit unbound list — deterministically, with no model.

Four document-import engagements have run to completion: one failed, three clean, 152 vouchers, zero rejections. Not one failure was OCR, parsing or model quality. Every failure was master matching. One engagement rejected 61 vouchers over seven missing ledgers, four of them near-misses of ledgers that already existed. One sales engagement met a customer whose ledger name differed from the source name entirely, where fuzzy matching offered three candidates and all three were the wrong person — only a number the operator had typed into the ledger name identified them, and it matched exactly.

Bridge was growing two answers to this. agent_import.rs::master_match (MCP validate_masters) compared a folded key by equality or prefix and, for a near-miss, emitted exact_live_spelling: candidates.first() — naming one candidate as the live spelling with no evidence that it is. That is a silent auto-resolution of the exact case that caused the 61-voucher failure. #276 gave the desktop the opposite and correct behaviour — the operator assigns explicitly and nothing is ranked — but it narrows nothing either, so the operator faces the whole catalog per entry. Neither surface matched on an embedded identifier.

ADR 0016 is the deliverable to review first. It fixes the contract both surfaces consume; the code follows it.

Scope, reuse, and impact

  • Scope: the binding contract and its rules, plus both consumers. Explicitly excluded: master creation, stock-item catalogue reads, ledger or stock-item write authority, voucher generation, posting, any change to what may be written, and any UI change (the desktop narrowing is returned by an existing command; wiring it into the screen is a separate slice).
  • Existing component reused: bridge-tally-core (the portable contract layer that already holds reconciliation); party_name for egress redaction; Prepare source proposals with observed existing ledgers #276's catalogue capture, its bind_selected/matches admission path and its SourceDraftCatalogTargets; the existing validate_masters tool and its call sites; the existing candidate-count and byte bounds.
  • What is deleted: agent_import.rs::master_match and its private master_key (−56 production lines there), and unicode-normalization moves out of the root crate's dependency list down into bridge-tally-core — the binary's dependency set is unchanged, and no dependency is added to the tree.
  • What breaks if this is not built: the two implementations diverge permanently — Show unanswered choices in source draft rows #277 is already stacked on the operator-facing half — and neither surface ever matches on an identifier, which is the single rule that decides the case fuzzy matching demonstrably got wrong.
  • Net LOC: production +1,424 −56; tests/fixtures +601 −19; generated (lockfiles, compatibility artifacts) +21 −11.
  • Migration/sync compatibility: none. No database, schema or saved-draft change; saved drafts remain compatible. Rollback is a revert of this commit: validate_masters returns to prefix matching and the desktop returns the full catalogue with no bindings field.
  • Destructive database migration: No.
  • Security impact for DSC, Tally, credentials, endpoints, or customer data: no credential, DSC or endpoint change, and no new Tally request of any kind — binding is pure computation over data both callers had already read. The MCP result reports an unbound entity's unresolved_identity wrapped in the same party-name marker as every other name, so egress redaction treats it identically; it adds no exposure, because those identifiers are extracted from the requested name the same result already echoes. bridge-tally-core holds no GUID and grants no authority.

The three rules

  1. The identifier is the key; the name is a hint. A digit run of ≥8 digits, or a mixed letter-and-digit token, is matched before any name comparison. An 8-digit run that reads as a calendar date is excluded, so two period-labelled masters cannot fuse on their period.
  2. Nothing binds unless it is unique on both sides. An identifier carried by two masters is Ambiguous, never a bind. An identifier and an exact name pointing at different masters is reported rather than settled.
  3. A near-miss is never resolved. Candidates carry the rule that surfaced them — SharedIdentifier, NormalizedEqual, CatalogPrefix, SourcePrefix, SharedToken — ordered by rule and name. No candidate is marked best and no score is emitted at all, because a score invites a threshold and a threshold auto-resolves. A single candidate still does not bind.

How it fails closed

  • An empty catalogue is CatalogEmpty, a typed refusal — not a report full of "missing". "Nobody read the ledger list out of Tally first" is the recorded cause of the one failed engagement, and P5 requires nothing-found to stay distinguishable from request-failed.
  • A duplicate catalogue name, an unusable name, an over-long entity list and an identifier hint that yields no identifier are all typed errors at the constructors (P3). bind then has exactly one failure mode left.
  • FallbackBinding::assign can only be constructed from an entity that did not bind and a fallback master that is a current catalogue entry, so parking something that already matched — and parking against a suspense ledger that does not exist, which is how one batch was lost — are both unrepresentable (P2).
  • BindingReport::totals() reports requested/bound/ambiguous/unmatched/unbound, with requested == bound + unbound asserted by test.

Behaviour changes on the MCP surface

  • match_state gains normalized and identifier alongside exact, near_miss, missing. exact_live_spelling now appears only on a bound row.
  • The old code classified every normalized-equal name as a near-miss, so a request differing from the live ledger only in case, whitespace or dash style produced a candidate list instead of an answer. Those now bind and report the live spelling.
  • build_import_xml and the approved-post recheck still admit exact only. The import file carries the name verbatim, so a normalized or identifier bind informs the operator without widening what may be written. This PR does not move the write gate.

Validation and evidence

  • Exact candidate SHA: b98569ff26273b048f8c3bd4355d3f23d8d37b7c.

  • cargo test --locked --workspace (src-tauri): 855 lib + 80 bridge-tally-core + every other target passed, 0 failed. 31 of the core tests are new (the binding rules) and 2 are new in source_draft::catalog (the desktop narrowing).

  • cargo clippy --locked -p bridge-tally-core -p bridge-tally-protocol -p bridge-tally-transport -p tally-protocol-simulator --all-targets -- -D warnings: clean. cargo clippy --locked --lib -- -D warnings: clean. cargo fmt --all -- --check: clean.

  • corepack pnpm run build (tsc && vite build): passed. vitest run scripts/source-draft-screen.test.tsx: 31 passed.

  • Compatibility gate: passed, 210 pins, 11 unknown, 0 evidenced claims. Surface SHA-256 da5d736348b41f9bab9903cef0a7e93374831dbda5c3b29a357eeaf6fa4cc1dd, matrix SHA-256 83d31bc9114410015c44a5366bdd7118b1e0145b9a2c517581e8c35e4e839a88. Both artifacts were regenerated with the supported tooling (rehash-surfaceseal-surfacerepoint-matrix) rather than hand-edited: exactly eight pin digests move, none added or removed. check-matrix-markdown reports current.

  • tools/Cargo.lock gains exactly the ten lines for unicode-normalization; cargo check --locked in tools confirms the lockfile is what cargo resolves, with no unrelated crate moved.

  • Captured/fixture/live scope and known limitations: binding performs no I/O, so it has no live scope of its own. P1 is satisfied upstream — its inputs come from the catalogue read that already carries live evidence. Its own fixtures are fabricated from a placeholder alphabet (Greek-letter party names, PH- stock codes, numbers from a 55500000xx placeholder block); they establish the behaviour of the rules and are not, and may not be presented as, evidence about any Tally instance. Not established: any live run of the new report against a real book, the desktop narrowing on a packaged build, and the stock-item half — MasterClass::StockItem is covered by contract and by test, but no stock-item catalogue read exists yet, so both shipped consumers pass Ledger.

  • Manual/UI evidence: none applicable — no UI change. The desktop command returns a new advisory field the screen does not yet render.

  • One completed review-checklist.md line is linked here: New or changed commands validate inputs and surface user-safe errors.

  • Native Windows validation completed or explained as not applicable — not applicable: no platform-specific code; the change is portable Rust plus one TypeScript type.

  • Native macOS validation completed or explained as not applicable — not applicable for the same reason; the frontend build and the full Rust suite ran on macOS.

  • Rectify issue linked — not a regression fix.

Checklist

  • Security implications reviewed (especially DSC, Tally, and credential flows) — no new Tally request, no credential path, no widening of the write gate.
  • Migration compatibility and rollback impact documented
  • Error handling paths still return actionable errors — every refusal carries a stable safe_reason_code.
  • No leftover debug logs with sensitive values
  • No personal/customer data, certificate output, local usernames, or developer-specific absolute paths added — the staged diff was scanned by pattern (10+ digit runs, GSTIN/PAN/IFSC shapes, emails, UPI VPAs, /Users/ paths, and known client tokens). The only long digit runs are the documented 55500000xx placeholder block and one fabricated XML date; every other match was a content hash.

🤖 Generated with Claude Code


Where the desktop fields are consumed

SourceDraftCatalogTargets.bindings and bindings_state are returned by this PR and rendered by #290, which is stacked on this branch and MERGEABLE. Reviewing this PR alone, they read as dead fields; they are not, and the split is the repository's established idiom — #276 added the catalogue capture and #277 consumed it.

What this PR does establish about them on its own: source_entry_bindings is covered by backend tests here — a_capture_narrows_each_source_entry_without_deciding_a_near_miss and a_narrowing_pass_that_could_not_run_says_so_rather_than_looking_empty — so the field is verified as produced correctly, and #290 verifies it is drawn correctly. Neither PR verifies the pair on a packaged build; that gap is stated in #290 and is unchanged.

Merge order for the stack is #288#290#294.

The cap arithmetic has moved and is now read from the branch, not from here. #278 landed its own raise 210 → 211 for src-tauri/src/agent_ledgers.rs while this PR was open, so two independent raises met in the rebase and this PR now carries 212 — with both reasons recorded at the constant, because a merge keeping one raise and one pin would pass the gate with the other file silently unpinned. Downstream numbers quoted in an earlier revision of this description are stale by construction; take them from the branch.

Every failure across four document-import engagements was binding a
document's entities to the target book's masters, never reading the
document. Bridge was growing two answers to it: an MCP-private prefix
matcher that named one near-miss candidate as `exact_live_spelling`, and
a desktop screen that correctly ranks nothing but narrows nothing either.

Add `bridge_tally_core::master_binding` as the single contract both
surfaces consume, per ADR 0016. It matches an identifier embedded in a
master name before the name itself, binds only where a rule is unique on
both sides, and never resolves a near-miss: it reports candidates with
the rule that surfaced each, and no score. An empty catalogue is a typed
refusal rather than a report full of "missing".

Deletes `master_match` and `master_key` from agent_import and moves
unicode-normalization down a layer with them. The write gate is
unchanged: build_import_xml and the approved-post recheck still admit
byte-exact names only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-12T08:24:15.568615Z 404c2e3 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b98569ff26

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/src/agent_import.rs
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Comment thread src-tauri/src/source_draft/catalog.rs Outdated
Comment thread src-tauri/src/source_draft/catalog.rs Outdated
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Comment thread docs/tally/compatibility/compatibility-surface.json
Comment thread src-tauri/crates/bridge-tally-core/src/lib.rs
lamemustafa and others added 2 commits September 10, 2026 22:13
…scale

Self-review found a real defect: a catalog name was trimmed at the
boundary, so a bound row reported a spelling the book does not contain.
The write gate compares byte-exact against Tally's own name, so that
would have refused with no explanation. Observed names are now retained
verbatim; only source names are trimmed. Names differing solely in
surrounding whitespace are an ambiguity, not a refused catalog.

Adds a characterization suite over one fabricated 200-master book with
the recorded naming pathologies. The assertion that matters is that no
entity binds to a master a human would not have chosen; the counts are
pinned underneath so loosening a threshold has to move a number.

The mutation sweep was checked against two positive controls rather than
trusted for passing: resolving a near-miss to its first candidate trips
it, and binding a lone candidate does not. Both results are recorded in
the test, so it is read as "no mutation reaches the wrong master" and
not as "no rule change can loosen binding".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rzakhani-94af40

# Conflicts:
#	docs/tally/compatibility/compatibility-matrix.json
#	docs/tally/compatibility/compatibility-surface.json

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5e3ed29383

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Comment thread src-tauri/src/agent_import.rs Outdated
Comment thread src-tauri/src/source_draft/catalog.rs
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
@lamemustafa

Copy link
Copy Markdown
Owner Author

Live evidence against the synthetic books — and it does not support this PR as it stands

Review thread on bridge-tally-core/src/lib.rs asks for a live end-to-end slice before this binder is exposed, on the grounds that the added coverage is fabricated and the desktop screen does not consume the returned bindings. That thread is correct, I ran the evidence it asked for, and the result is worse than the review found. Reporting it here before fixing anything.

What was run

All 16 loaded synthetic companies on the local licensed TallyPrime 7.1 (Silver, education_mode: false) were read with Bridge's own StandardLedgerCatalogV1 request shape, raw responses written to files and parsed from the files rather than through a pipe. 470 real ledger names. Each name was then mutated the way a source document mutates a name it copied — uppercase, lowercase, respaced, truncated to 80%, last word dropped — and every mutation was bound against its own book. 2,257 cases.

What held

  • 16 of 16 books were accepted by MasterCatalog::new; no live catalogue is refused.
  • Devanagari, Tamil, Bengali, accented Latin, curly quotes and an em-dash all bind correctly through the NFC and dash/quote folding.
  • Zero wrong binds. Four cases initially flagged are truncations that spell a different real ledger (Input CGST 9% BR minus its last word is Input CGST 9%, which exists); binding those to the name they actually spell is correct, and no rule can do better.

What did not hold

measure result
median candidate-list length 40
mean share of the whole catalogue offered as candidates 62.8%
unbound rows whose candidate list contains the right master 65.6%
unbound rows exceeding the 25-candidate cap 440 of 843

The narrowing does not narrow, and roughly a third of the time the right answer is not in the list.

Cause, isolated: CatalogPrefix produced 12,108 of 12,793 listed candidates (SharedToken 683, SourcePrefix 2). BRIDGE CORPUS DENSE names its parties DN Party 001DN Party 120. A truncated source name reduces to DN Party 0, which prefixes all 120; the list is capped at 25 ordered by rule then name, so the operator is shown DN Party 001DN Party 025 and DN Party 057 is silently outside the cap. candidate_count reports 120 truthfully, but truthful counting does not put the answer on screen. Systematic numbered naming is not a corpus artifact — it is how real books name parties and series ledgers.

So the fabricated characterization suite in this PR is not wrong, it is not discriminating: its book has no large name family, so the cap never bites. That is the P1 in AGENTS.md operating exactly as written, and I built past it.

The corpus cannot qualify the rule this feature exists for

Of the 470 live names, zero yield a numeric identifier and exactly one yields a code identifier. The identifier-before-name rule — the entire reason this is not fuzzy matching — has no synthetic book that exercises it. Any claim about it currently rests on fabricated data alone.

On the other review threads

Read against the tree, not taken at face value. The correctness findings are accepted:

  • Byte-exact identifier-bearing names — confirmed and severe. bind_one selects Identifier before ExactName, so a ledger carrying a number, requested byte-exactly, returns match_state: identifier, and both build_import_xml and post_import admit only exact. Ledgers with an embedded number become permanently unimportable — the exact population this feature was built for. Fixing so that agreement between an identifier and a byte-exact name reports exact.
  • Raw names for byte equality — partly addressed in 407b782 (observed catalogue names are now retained verbatim), but the source side still trims, so Bank can claim exact against live Bank while the writer emits Bank . Still open; the raw requested value must decide byte equality.
  • Identifier overflow, digit suffixes inside mixed codes, non-year-first dates, the swallowed TooManySourceEntities, aggregate desktop output bounds, and catalogue evidence on a binding refusal — all reproduce on reading; all being fixed.
  • master_binding.rs absent from the compatibility surface — correct, and it matters more now that the matcher decides admission outcomes. Being added to the sealed surface.

Plan

  1. Fix the correctness findings above.
  2. Fix candidate quality so the cap stops discarding the answer — order by specificity rather than alphabetically, and tighten CatalogPrefix against large name families.
  3. Re-run this live probe as the acceptance measure. Target: the right master is in the list essentially always, and the list is short enough to read.
  4. Extend the synthetic corpus with identifier-bearing ledgers, then qualify.

Stacked PR #290, which renders these candidates in the preparation screen, is held until (2) and (3) land. It faithfully draws a list that live data says is the wrong list.

Ten review findings, read against the tree rather than taken at face
value, and all reproduced. The severe one: bind_one selected Identifier
before ExactName, so a ledger carrying a number, requested byte-exactly,
returned match_state identifier while the write gate admits exact only.
Every ledger with a phone or account number in its name was permanently
unimportable — the exact population this contract was built for. When
the two signals agree it now reports the byte-level fact.

Also: neither side trims a name any more, so a trailing space cannot
claim byte equality it does not have; digits inside a mixed code are no
longer emitted as a standalone numeric; all admitted eight-digit date
orders are excluded, not just year-first; more identifiers than the
bound is refused rather than truncated, which could hide a conflict; the
source-entity bound now covers what the source parser admits and the
desktop states whether narrowing ran; desktop candidate bytes are capped
in aggregate; a binding refusal keeps the catalogue evidence it already
read; and master_binding.rs is sealed into the compatibility surface,
which needed a deliberate one-file cap raise since it now decides
admission outcomes.

The larger finding came from running the binder over 470 real ledger
names from all 16 synthetic books. Prefix matching offered a median of
40 candidates, 63% of the catalogue, and omitted the right master a
third of the time: a truncated name reaches a whole family, and an
alphabetically capped slice of DN Party 001..120 does not contain
DN Party 057. A prefix matching more masters than the cap is now counted
and deliberately not listed. Re-measured on the same names: where
candidates are listed the right master is present in 403 of 403 rows,
median list length 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 81bc763622

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/src/source_draft/catalog.rs Outdated
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Comment thread src-tauri/src/agent_catalog.rs
The remaining review finding, and the one I had triaged but not fixed.
FallbackBinding::assign took an EntityBinding detached from its report
plus any catalog, so a stock-item binding could be parked against a
ledger catalog and the result carried no provenance for anything
downstream to detect.

Assignment is now a method on BindingReport taking an index into its own
entities, so an entity from another report cannot be named at all, the
catalog class is checked, and the binding carries its class forward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eea460efe5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
lamemustafa and others added 2 commits September 11, 2026 00:03
A fix is a change and generates its own findings; the re-review of the
previous commits raised four, all reproduced.

An unusable ledger name in a parsed draft was skipped while the response
still claimed a complete narrowing pass, so the rows that vanished were
exactly the ones worth looking at; the pass is now reported unavailable.
The reported candidate total took the larger of the suppressed family and
the retained candidates, which under-reports when they are different
masters; it is now their union. An identifier hint reached extraction
without the bound applied to every other name. And the operator workflow
in docs/agent/README.md still told readers to correct only near_miss
rows, which now leaves a bound-but-not-exact row refused at the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two further findings from the re-review, both reproduced.

`Purchases FY25` and `Sales FY25` both yielded the code identifier
`FY25`, so identifier-first matching bound the source to whichever
existed before it ever compared the names. A fiscal-period label
identifies a period, not a party, and is now excluded by shape; the
minimum code length also rises from four to six, since a four-character
mixed token is weak evidence of identity and the failure mode here is
money against the wrong party. Re-measured against the same 470 live
ledger names: no change to the distribution, so the tightening costs
nothing observed.

Candidate collection recounted every prefix from the start of the name,
making it quadratic in a field the source parser lets reach 4 KiB. It
now carries the character count forward in one pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: acef951d5a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Comment thread src-tauri/src/source_draft/catalog.rs
lamemustafa and others added 2 commits September 11, 2026 00:43
No book on the instance carried an embedded identifier: across 470 live
ledger names from all 16 loaded companies, zero yielded a numeric
identifier and exactly one a code identifier. The rule that separates
this from fuzzy matching had no live coverage at all.

Ten `MB ` ledgers now exist in BRIDGE CORPUS OPENING, parented to
Suspense A/c so no receivable, payable or ageing measurement moves, and
documented in TEST_CORPUS.md section 9 with the import method and the
company-choice reasoning. BRIDGE PROBE B SANDBOX was rejected as the
target despite the manufacturing precedent: it shares a GUID with a
second loaded company and Bridge's own reads refuse it as
company_identity_ambiguous.

Within minutes the pair sharing one identifier exposed a defect no
fabricated fixture had produced. A byte-exact request for a ledger whose
embedded number is shared with another was refused as
IdentifierConflict, making that ledger permanently unimportable, since
the write gate admits exact only. Byte equality with an observed master
name is now decisive: it names exactly one master, and an ambiguous
identifier does not undermine it. Only a decisive identifier pointing
elsewhere still outranks an exact name, and that stays a reported
conflict.

Re-measured over 485 live names, 2,330 cases: identifier binds 3 -> 11,
every uppercase mutation now binds, and where candidates are listed the
right master is present in 434 of 434 rows at a median list length of 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings from the latest re-review, both reproduced.

Enumerating the period shapes that must not become identifiers was a
losing game: FY25 was fixed, then APR2025 and 2025Q1 were still binding
two unrelated ledgers that merely share a period. The rule is now a
shape — every run in the token is a short alphabetic marker or a number
reading as a year or small ordinal, at most three runs — and a code
identifier additionally needs eight alphanumerics, three digits and two
letters. Requiring real length is the part that does not depend on
having thought of every label. Measured against 485 live ledger names,
exactly one yields a code identifier at all, and it still does.

The aggregate candidate budget was applied to the consumer's copy, so
the report's own clones were already allocated by then; capping the copy
bounded only the copy. The budget now lives in bind() and is spent in
entity order, and the desktop's second budget is deleted as redundant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b87c8a3403

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Comment thread docs/adr/0016-master-binding-authority.md Outdated
The voucher-presence lane needs the same fold for voucher numbers and
voucher-type names that master names use, and is exposing this function
crate-wide to get it. That is the right call — a second, subtly
different normaliser is the divergence ADR 0016 exists to end, and it
would diverge silently, agreeing on every name tested by hand and
differing on the punctuation nobody thinks to try.

Records that obligation at the function, and its corollary: changing
what this folds changes every consumer's notion of sameness at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 03280f2b5a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
lamemustafa and others added 2 commits September 11, 2026 01:48
A client-supplied REMOTEID is not readable back — Tally overwrites the
attribute with its own value (IMPLEMENTATION_GUIDE.md §3.3a, fourth
property, verified). Nothing here uses it, but the doc on
unresolved_identity said only that the identity is retained "for later
reallocation" without naming the channel, and the obvious wrong choice
fails silently: an amount parked with its identity in a write-only field
is unreallocatable, and nothing about the write says so.

Says narration, and says why. Surfaced by the voucher-presence lane,
which had to correct three claims of its own after reading the
reference's §9.3 heading as the general case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…facts

candidates can be empty because nothing resembles the name, because a
family resembles it and none is separable, or because the list was cut —
and those mean opposite things to whoever decides what to do next. The
disambiguators are reason, candidate_count and candidates_truncated, and
reading the empty vector alone is wrong in two cases out of three.

Recorded here rather than left to each consumer because it has already
been got wrong twice by different lanes: the preparation screen rendered
"0 possible ledgers are listed first" over a family of 120, and the
voucher-presence contract needed a paired test to stop its rule
collapsing into "no candidates means unknown". Same defect class this
ADR was written against — a refusal whose neighbouring value reads as an
answer.

Also records why this is a doc and not a type. An enum of Listed /
Truncated / Withheld / None is the stronger fix and the one P2 asks for,
but it is breaking, a stacked consumer already depends on
candidates_truncated as a predicate and holds the boundary with tests,
and forcing that rework mid-review trades an improvement for a
regression risk. Revisit once both have merged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 46a157d698

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
…isting

Five items in one round, because review reopens once either way and the
marginal cost of the rest once it is reopened is small.

Tally's master-name matching is measured, not guessable:
IMPLEMENTATION_GUIDE.md §3.3b found it case-insensitive AND
separator-insensitive — a hyphen matches a space — and otherwise exact.
The binder was stricter, which is not the safe direction it looks like:
it refused names Tally accepts, and `X - Y` is a common ledger
convention. A separate master_identity_key follows §3.3b and stops where
Tally stops; `AND` for `&`, a missing suffix word and a singular for a
plural still refuse. It is separate from comparison_key rather than a
widening of it, because that one is shared with voucher numbers and
voucher-type names and §3.3b says nothing about those. Measured live: 16
of 16 hyphenated masters now bind from the spelling Tally itself
accepts, where all 16 were near-misses before.

Candidates becomes None | Listed | Truncated | Withheld. An empty vector
was three different facts and a consumer reading is_empty() was wrong in
two of them, a shape already got wrong twice by different lanes. Taken
before merge because the contract has not shipped and this is the
cheapest it will ever be; the consumer who pays for it measured thirty
lines and reported the change improves its code. The MCP result gains an
explicit listing discriminator, since a model is the caller that would
read an empty array as "no such ledger exists"; the desktop DTO stays
flat, where the screen already distinguishes the cases and is tested.

Also: FallbackBinding says reallocate with a Journal and never Alter or
Cancel, which §9.7 measured as duplicating with the target untouched
while reporting success; the ADR records that identifier coverage is
bimodal by client (42%, 0%, 0%, 0%) so the rule is a first-pass check
and never a primary key; and BindingStatus says what a Bound does not
establish — not that the master still exists, not that the requested
name may be written, not that it is right in business terms, and no
authority at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a6d95adebe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Three were the same shape recurring: an identifier built from something
that identifies a period rather than a party.

A token carrying letters now never yields a standalone numeric, whether
or not it qualified as a code — `Part A12345678` was reaching an
unrelated `Bank 12345678` through the one-letter gap the code test
rejects. Period labels are recognised by their numbers rather than their
words, which catches `SEPTEMBER2025` and `2025QUARTER1` that no cap on
the alphabetic run ever would: a month name can be any length, a year
cannot. And a fiscal range is excluded before its digits are fused,
since `2025-2026` strips to an eight-digit run no calendar reading
rejects.

Fallback assignment now checks catalog provenance, not just class: two
ledger catalogs are both Ledger, and a fallback drawn from the one the
report never saw names a master that was never a candidate. Candidate
collection selects by index and clones only what it retains, instead of
cloning every match before the cap and the budget discard most of it.

ADR 0016 quoted thresholds this module stopped using two rounds ago, and
it is the contract two surfaces integrate against. Synced — and a test
now reads the ADR and asserts it quotes the live constants, so the next
drift fails rather than waiting to be noticed. Verified against a
positive control: changing a constant without the document fails it. It
also caught a false positive of its own on first run, which was the
detector being too strict about `(10%)` rather than the ADR being wrong.

Live re-measure over 485 names, 2,330 cases: 434 of 434 listed rows
still contain the right master, median listed length 2, no wrong binds.
Identifier binds 11 -> 8 with bound and unbound totals unchanged: three
mutations that had bound to themselves through a leaked numeric now bind
by name instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 168e094e14

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Comment thread src/source-draft-types.ts
…ializable

Both reproduced by execution before fixing, and both re-verified against
the same reproduction after.

A token shared across a name family IS the family. Where the catalog is
large enough that the token stays under the common-token threshold — 30
rows among 330 is 9% — the shared-token pass re-offered exactly the rows
the prefix pass had withheld, restoring the arbitrary capped slice that
withholding exists to prevent. Measured: the case reported near_miss
with 25 of 30 listed and the intended master outside the slice; it now
reports no_discriminating_candidate with the family counted and none
listed. Weaker rules now skip the withheld set; decisive rules still
reach a family member on its own evidence, because the identifier or the
whole key is what separates that one from its siblings.

Candidates::Listed was a newtype variant carrying a sequence, and under
Serde's internally tagged representation a tag cannot be merged into a
sequence — so serializing the MOST COMMON unresolved result failed at
runtime while the other three variants succeeded. Now a struct variant.
No test caught it because none had ever serialized an Unresolved, only a
Bound; every variant now round-trips in a test.

Live re-measure unchanged: 434 of 434 listed rows contain the right
master, median listed length 2, no wrong binds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lamemustafa added a commit that referenced this pull request Sep 10, 2026
#288 replaced Unresolved's vector-plus-two-flags with a Candidates enum
after this lane voted for it and priced the change at thirty lines. The
estimate held: one function, two arms, two test literals.

The point was never compatibility. The predicate was

    incomplete: unresolved.reason == UnboundReason::NoDiscriminatingCandidate
        || unresolved.candidates_truncated

— a hand-assembled disjunction over two fields, which is the line that
collapses into "no candidates means unknown" if edited carelessly, and
the reason it needed a paired test to hold it. It is now an exhaustive
match over None, Listed, Truncated and Withheld, so a new variant does
not compile until this decides what it means, and the difference between
"nothing resembles this party" and "a family we refuse to slice" lives
in one place instead of being reconstructed from a reason code.

The UnboundReason import is gone: the type carries what the reason
comparison was standing in for. Net effect is fewer lines and one less
thing to remember.

167 core and 865 lib green, clippy and fmt clean, reseal run and gate
passing at 213 pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rzakhani-94af40

# Conflicts:
#	docs/tally/compatibility/compatibility-matrix.json
#	docs/tally/compatibility/compatibility-surface.json

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2025fc2ccf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs
t and others added 3 commits September 12, 2026 12:43
The previous round's fixes each opened a smaller hole behind them.

- Identifier canonicalisation dropped `-` and `/` but nothing else, so two
  codes differing only in unmeasured punctuation collapsed into one. It now
  removes only the two separators the gateway was measured to fold.
- A family larger than the cap was skipped without recording its size, so a
  withheld family reported no count at all. It now reports how many share the
  identifier even when the holders are not expanded.
- A hint pointing away from an exact name was only honoured below the cap.
  An exact name no longer outranks a hint at any family size.
- A stock item could resolve on a fold. It may now suggest on one and
  nothing more, and the test asserts the reason, not only the outcome.

Three of the round's earlier tests asserted the outcome where the fix was
about the work done to reach it, and passed with the fix reverted. Two
test-only counters (`HOLDER_EXPANSIONS`, `CANDIDATE_SEARCHES`) now let them
assert the work.

§9.4d recorded `AND` for `&` as rejected, but the probe that produced the row
sent a name with `AND CO` appended, against a master carrying no `&`. That
measures an added suffix. The substitution was re-measured against
`Profit & Loss A/c`, a reserved ledger present in every company, and the
section now carries four accurately-labelled rows and says which of them it
measured. The verdict was unchanged; the label was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rzakhani-94af40

# Conflicts:
#	docs/tally/compatibility/compatibility-matrix.json
#	docs/tally/compatibility/compatibility-surface.json
The method paragraph still described the first run alone — twelve variants,
eight vouchers deleted — after the re-measurement added six more. It now
counts both runs.

`the_master_fold_stops_where_tally_stops` cited "§3.3b" without naming the
document that carries it, which is `IMPLEMENTATION_GUIDE.md`, not this
reference; the same three rows now also point at §9.4d, where two of them
were re-measured on licensed 7.1.

Compatibility surface resealed for the changed pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4fe09f410b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs
t and others added 2 commits September 12, 2026 13:15
… fixes

**A byte is not a unit of evidence.** The code threshold was `canonical.len()`,
which is UTF-8 bytes. An admitted dash variant is three of them, so `AB–123`
measured eight against a constant named `MIN_CODE_IDENTIFIER_CHARS` and became
decisive on five alphanumerics — while its ASCII twin `AB-123` reduces to
`AB123` and is refused. The same code identified or did not depending on which
dash the document carried. It now counts letters and digits, which also closes
the padding shape bytes could never see. The upper bound stays in bytes: it
bounds what the index stores, and a byte cap admits no more characters than it
says.

**One family's size is not the size of their union.** A skipped family of
thirty beside an identifier reaching a thirty-first reported thirty. The listed
masters not in the skipped family are provably disjoint from it, so they are
added — a bisection per master, no set built. It stays a lower bound, and the
field now says so rather than describing the implementation.

**The memo's repeat count read half its key.** Entries are keyed by source key
*and* the masters the identifiers reached, but repetition was counted on the
key alone, so 1,024 hint variants of one name filled a cache nothing asks for
twice and the pair that did repeat behind them could not be inserted — the
source-order defect again, through the back door. Counted on `(key,
identifiers)` now, still borrowed.

**Only one side of an untrusted pair was bounded.** The catalog constructor has
an aggregate byte budget because count and length bounds do not bound their
product; the source side had none, and 40,000 individually valid entities are
two and a half gigabytes of names before this module clones one. 16 MiB,
checked at `bind`, counting both retained folds beside the name.

Each fix has a test whose control is the fix reverted, and each control fails
its own test alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 57380c0d1a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs Outdated
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs
Comment thread src-tauri/src/agent_import.rs Outdated
…ded its own reason

**A separator reveals a date and hides two.** The date guard ran on the
canonical only, where removing `-` turns `2025-09-11` into a recognisable
`20250911`. It also turns `DATED20250911-20250912` into one sixteen-digit run
that reads as no date at all, and `is_period` does not see it either, because
its eight-digit case admits a year followed by a year and `0911` is neither. A
date range identified, and a period label is the single thing two unrelated
masters most reliably share. The guard now runs on both spellings; neither
subsumes the other. What is *not* fixed is written above `carries_plausible_date`:
a partly separated range still escapes all three guards, and the obvious
generalisation was considered and rejected, because refusing every sixteen-digit
account number containing a date-shaped window throws away the identifiers this
module exists to use.

**The memo refused to hold the searches it existed for.** `worth_holding`
excluded results larger than the candidate cap, reasoning that a large result
costs what recomputing it costs — which assumes it is recomputed once. A name
reaching a large family through shared tokens, repeated across a draft, is the
case where it is recomputed per row, and it was exactly the case excluded.
`collect_candidates` now sorts into the caller's order and truncates, so every
result is holdable and the per-row allocation of a whole family is gone;
`found` is still the full union, so the count is unchanged. The ordering is one
function used by both the truncation and the sort, because a disagreement
between them would drop a candidate silently. The now-vacuous size test is
removed rather than left as a trap.

**The candidates came from a different index than the reason.** `NameAmbiguous`
is decided on the resolving fold and the list was built from the wide one,
which is not always coarser: `master_identity_key` replaces `-` but not `/`, so
`AB/CD` and `AB CD` are one master to `verified_fold` and two to it. The
operator was shown an ambiguity with a complete-looking list of one. The
resolving-key holders are offered too — correct however the two folds relate,
where widening the wide fold would fix one example and leave the list assembled
from the wrong index.

**A request refusable from its own arguments spent two live reads first.** The
tool schema admits names the core refuses. Parsing now happens before
`verified_company` and before the catalogue read, and `master_report` takes
parsed entities so the ordering is not a convention to remember.

Each fix has a test whose control is the fix reverted. The last one's control
is the endpoint: nothing is listening on it, so a read attempted before parsing
comes back as a failure to connect rather than `master_name_unsafe`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 404c2e3308

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs
Comment thread src-tauri/crates/bridge-tally-core/src/master_binding.rs
lamemustafa pushed a commit that referenced this pull request Sep 12, 2026
Three sessions hit the same defect independently in one day: a pinned file's
bytes moved and the compatibility surface was not resealed. Once by editing,
once by running `cargo fmt` *after* resealing, and once by rebasing — where the
rebase takes the base's manifest and the author touches nothing.

That spread of causes is the point. The rule people had written down was "the
reseal is the last step before `git add`", and each of us broke it while
believing we were following it, because each filed it under the *situation* we
had just been in rather than under the actual invariant: **any operation that
can change the bytes of a pinned file — an edit, a formatter, a merge, a
rebase — invalidates the seal, and the reseal runs after the last of them.**

Nothing in the local loop re-reads pins before a commit, so CI's gate is the
only thing that notices, and every instance therefore reaches a reviewer
instead of its author. That makes it a class, not a set of mistakes, and a
class is worth closing here rather than writing down again.

The check needs no checkout: read the 211 pinned paths from the manifest at the
PR head, intersect with the PR's changed files, and require the manifest to
have moved if any of them did.

It is deliberately weaker than CI's gate and says so: it proves the reseal was
*performed*, not that the hashes are *right*. Only the real gate proves that.
But every instance observed was a reseal that never ran at all, so this catches
the whole observed failure while costing one API call.

Verified in three directions rather than two: a pinned file changed without the
manifest blocks; the same change with the manifest passes; a PR touching the
manifest alone has nothing to reseal and passes. Against live PRs, #314 reports
one pinned file with the manifest moved alongside it, and #320 reports nothing
pinned.

Credit where due — this was suggested by the lane on #288, which had just been
bitten by the `cargo fmt` variant, on the grounds that closing the class beats
closing the instances. It was right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lamemustafa
lamemustafa merged commit 3de262a into master Sep 12, 2026
13 checks passed
@lamemustafa
lamemustafa deleted the claude/recursing-mirzakhani-94af40 branch September 12, 2026 08:30
lamemustafa pushed a commit that referenced this pull request Sep 12, 2026
Five findings, three P1, and the first one voids the guard entirely.

**The master-creation guard sat inside step 4, which begins "after counters
accept".** It ran after dispatch. By then `ACTION="Create"` against a name
Tally considers matching has already returned `CREATED=0, ALTERED=1` and
overwritten the existing master with the retry payload (guide §3.6, plan §8.5).
A refusal raised during readback protects nothing — the client's master is
already gone. It is now step **3a**, on the intent, before any import is sent.

**And the harm was described wrongly, in the safer direction.** The guard said a
near-collision "makes a SECOND master differing only by case". The measured
behaviour is an overwrite in place: the existing master's group, opening balance
and GST registration are replaced by whatever the new payload carried. A
duplicate is visible in a ledger list. An overwrite is not.

**The detector omitted canonical equivalence.** §9.4b's exact-codepoint result
came from an EDU instance, so a licensed SKU that folds NFC/NFD is not excluded
— and an NFD request beside an existing NFC master would pass exact lookup *and*
the detector, and overwrite it. Canonical equivalents now collide. This does not
reintroduce NFC normalisation into matching: the binder still compares exact
codepoints. One folds to refuse, the other folds to write, and only the second
needs evidence.

**A read receipt cannot qualify a write predicate.** The gate offered "where the
compatibility result for the connected instance says it holds" as a widening
route. `compatibility/README` defines that evidence as a live-**read** receipt
and says it "never establishes ... any write behavior". Withdrawn here, in Phase
2 item 5, and in the plan's §8.6, all of which carried it.

**What does qualify, now that #288 has landed: §9.4d.** It re-ran §9.4b's method
on licensed 7.1 silver with `education_mode=false`, importing vouchers naming
folded spellings and reading the **day book** back to see which master each
posted against. That is observed write behaviour on the SKU this project writes
to — for **ledgers**. So ledgers on licensed 7.1 match under §9.4d's rows, and
every other master type still matches on exact codepoints, because §9.4d
measured nothing about them.

**§9.5 was carrying implementation policy under a `VERIFIED` marker.** The
measurement is `LASTMID=0`; "which name rule applies" is a choice. A reader
collecting VERIFIED claims would have collected one that was never measured. The
prescription is now a separate block that says so — the evidence marker governs
the paragraph it opens, and a prescription placed under it inherits a confidence
it has not earned.

Surface resealed after the rebase onto #288; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lamemustafa pushed a commit that referenced this pull request Sep 12, 2026
Three sessions hit the same defect independently in one day: a pinned file's
bytes moved and the compatibility surface was not resealed. Once by editing,
once by running `cargo fmt` *after* resealing, and once by rebasing — where the
rebase takes the base's manifest and the author touches nothing.

That spread of causes is the point. The rule people had written down was "the
reseal is the last step before `git add`", and each of us broke it while
believing we were following it, because each filed it under the *situation* we
had just been in rather than under the actual invariant: **any operation that
can change the bytes of a pinned file — an edit, a formatter, a merge, a
rebase — invalidates the seal, and the reseal runs after the last of them.**

Nothing in the local loop re-reads pins before a commit, so CI's gate is the
only thing that notices, and every instance therefore reaches a reviewer
instead of its author. That makes it a class, not a set of mistakes, and a
class is worth closing here rather than writing down again.

The check needs no checkout: read the 211 pinned paths from the manifest at the
PR head, intersect with the PR's changed files, and require the manifest to
have moved if any of them did.

It is deliberately weaker than CI's gate and says so: it proves the reseal was
*performed*, not that the hashes are *right*. Only the real gate proves that.
But every instance observed was a reseal that never ran at all, so this catches
the whole observed failure while costing one API call.

Verified in three directions rather than two: a pinned file changed without the
manifest blocks; the same change with the manifest passes; a PR touching the
manifest alone has nothing to reseal and passes. Against live PRs, #314 reports
one pinned file with the manifest moved alongside it, and #320 reports nothing
pinned.

Credit where due — this was suggested by the lane on #288, which had just been
bitten by the `cargo fmt` variant, on the grounds that closing the class beats
closing the instances. It was right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lamemustafa pushed a commit that referenced this pull request Sep 12, 2026
Three sessions hit the same defect independently in one day: a pinned file's
bytes moved and the compatibility surface was not resealed. Once by editing,
once by running `cargo fmt` *after* resealing, and once by rebasing — where the
rebase takes the base's manifest and the author touches nothing.

That spread of causes is the point. The rule people had written down was "the
reseal is the last step before `git add`", and each of us broke it while
believing we were following it, because each filed it under the *situation* we
had just been in rather than under the actual invariant: **any operation that
can change the bytes of a pinned file — an edit, a formatter, a merge, a
rebase — invalidates the seal, and the reseal runs after the last of them.**

Nothing in the local loop re-reads pins before a commit, so CI's gate is the
only thing that notices, and every instance therefore reaches a reviewer
instead of its author. That makes it a class, not a set of mistakes, and a
class is worth closing here rather than writing down again.

The check needs no checkout: read the 211 pinned paths from the manifest at the
PR head, intersect with the PR's changed files, and require the manifest to
have moved if any of them did.

It is deliberately weaker than CI's gate and says so: it proves the reseal was
*performed*, not that the hashes are *right*. Only the real gate proves that.
But every instance observed was a reseal that never ran at all, so this catches
the whole observed failure while costing one API call.

Verified in three directions rather than two: a pinned file changed without the
manifest blocks; the same change with the manifest passes; a PR touching the
manifest alone has nothing to reseal and passes. Against live PRs, #314 reports
one pinned file with the manifest moved alongside it, and #320 reports nothing
pinned.

Credit where due — this was suggested by the lane on #288, which had just been
bitten by the `cargo fmt` variant, on the grounds that closing the class beats
closing the instances. It was right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lamemustafa pushed a commit that referenced this pull request Sep 12, 2026
Five findings, three P1, and the first one voids the guard entirely.

**The master-creation guard sat inside step 4, which begins "after counters
accept".** It ran after dispatch. By then `ACTION="Create"` against a name
Tally considers matching has already returned `CREATED=0, ALTERED=1` and
overwritten the existing master with the retry payload (guide §3.6, plan §8.5).
A refusal raised during readback protects nothing — the client's master is
already gone. It is now step **3a**, on the intent, before any import is sent.

**And the harm was described wrongly, in the safer direction.** The guard said a
near-collision "makes a SECOND master differing only by case". The measured
behaviour is an overwrite in place: the existing master's group, opening balance
and GST registration are replaced by whatever the new payload carried. A
duplicate is visible in a ledger list. An overwrite is not.

**The detector omitted canonical equivalence.** §9.4b's exact-codepoint result
came from an EDU instance, so a licensed SKU that folds NFC/NFD is not excluded
— and an NFD request beside an existing NFC master would pass exact lookup *and*
the detector, and overwrite it. Canonical equivalents now collide. This does not
reintroduce NFC normalisation into matching: the binder still compares exact
codepoints. One folds to refuse, the other folds to write, and only the second
needs evidence.

**A read receipt cannot qualify a write predicate.** The gate offered "where the
compatibility result for the connected instance says it holds" as a widening
route. `compatibility/README` defines that evidence as a live-**read** receipt
and says it "never establishes ... any write behavior". Withdrawn here, in Phase
2 item 5, and in the plan's §8.6, all of which carried it.

**What does qualify, now that #288 has landed: §9.4d.** It re-ran §9.4b's method
on licensed 7.1 silver with `education_mode=false`, importing vouchers naming
folded spellings and reading the **day book** back to see which master each
posted against. That is observed write behaviour on the SKU this project writes
to — for **ledgers**. So ledgers on licensed 7.1 match under §9.4d's rows, and
every other master type still matches on exact codepoints, because §9.4d
measured nothing about them.

**§9.5 was carrying implementation policy under a `VERIFIED` marker.** The
measurement is `LASTMID=0`; "which name rule applies" is a choice. A reader
collecting VERIFIED claims would have collected one that was never measured. The
prescription is now a separate block that says so — the evidence marker governs
the paragraph it opens, and a prescription placed under it inherits a confidence
it has not earned.

Surface resealed after the rebase onto #288; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lamemustafa pushed a commit that referenced this pull request Sep 12, 2026
…ss, and key the memo on both folds

**The memo key named one fold while the search read two.** `collect_candidates`
reads `entity.binding_key` as well as `entity.key` — I added that on #288 so the
masters which *caused* a name ambiguity are the ones listed — and I did not
extend the key that guards it. `AB-CD` and `AB–CD` share a wide key, because
`comparison_key` maps every dash variant to `-`, and differ under the resolving
fold, because §9.4d sent an en dash at a live master and Tally rejected it. The
gateway's own refusal is what makes the keys diverge, and the memo crossed them:
one spelling could be served the other's candidates.

The key now carries both folds and the fingerprint hashes both. Only the key
half is observable — a coarser fingerprint cannot serve a wrong result, it can
only re-admit entries the pre-count exists to exclude, which needs a thousand
colliding keys against nine dash variants — so the fingerprint is right by
construction and its doc says so rather than a test implying otherwise.

**Count precision and listing completeness are different questions.**
`count_is_lower_bound()` derived the first from the second, so a prefix family of
a hundred masters — unioned with the listed candidates *before* the decision not
to show it, and therefore counted exactly — was reported to every consumer as
"at least 100". Under-claiming is the safe direction and still a wrong statement
about the book, and a hedge that fires when it need not trains an operator to
discount it where it means something.

It is a carried fact now, true in exactly one case: an identifier family was too
large to expand, so the count is the larger of two possibly-overlapping sets
rather than their union. Everything else computes a true union first.

The change failed exactly one test, and it was the finding's own example: an
agent assertion pinning `candidate_count_is_lower_bound: true` over a
hundred-ledger prefix family. The over-hedge was not merely unnoticed; a test
held it in place.

**The ADR named a field this PR removes.** It pointed desktop consumers at
`candidates_truncated`, which as a boolean was `is_incomplete()` and so could not
separate a withheld family from an exhausted budget — the distinction its own
table turns on. It now documents the listing state per boundary, and records
that the agent surface keeps a field of that name meaning something narrower:
its own rendering cap, which is why it can be true beside an exact count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lamemustafa pushed a commit that referenced this pull request Sep 12, 2026
Five findings, three P1, and the first one voids the guard entirely.

**The master-creation guard sat inside step 4, which begins "after counters
accept".** It ran after dispatch. By then `ACTION="Create"` against a name
Tally considers matching has already returned `CREATED=0, ALTERED=1` and
overwritten the existing master with the retry payload (guide §3.6, plan §8.5).
A refusal raised during readback protects nothing — the client's master is
already gone. It is now step **3a**, on the intent, before any import is sent.

**And the harm was described wrongly, in the safer direction.** The guard said a
near-collision "makes a SECOND master differing only by case". The measured
behaviour is an overwrite in place: the existing master's group, opening balance
and GST registration are replaced by whatever the new payload carried. A
duplicate is visible in a ledger list. An overwrite is not.

**The detector omitted canonical equivalence.** §9.4b's exact-codepoint result
came from an EDU instance, so a licensed SKU that folds NFC/NFD is not excluded
— and an NFD request beside an existing NFC master would pass exact lookup *and*
the detector, and overwrite it. Canonical equivalents now collide. This does not
reintroduce NFC normalisation into matching: the binder still compares exact
codepoints. One folds to refuse, the other folds to write, and only the second
needs evidence.

**A read receipt cannot qualify a write predicate.** The gate offered "where the
compatibility result for the connected instance says it holds" as a widening
route. `compatibility/README` defines that evidence as a live-**read** receipt
and says it "never establishes ... any write behavior". Withdrawn here, in Phase
2 item 5, and in the plan's §8.6, all of which carried it.

**What does qualify, now that #288 has landed: §9.4d.** It re-ran §9.4b's method
on licensed 7.1 silver with `education_mode=false`, importing vouchers naming
folded spellings and reading the **day book** back to see which master each
posted against. That is observed write behaviour on the SKU this project writes
to — for **ledgers**. So ledgers on licensed 7.1 match under §9.4d's rows, and
every other master type still matches on exact codepoints, because §9.4d
measured nothing about them.

**§9.5 was carrying implementation policy under a `VERIFIED` marker.** The
measurement is `LASTMID=0`; "which name rule applies" is a choice. A reader
collecting VERIFIED claims would have collected one that was never measured. The
prescription is now a separate block that says so — the evidence marker governs
the paragraph it opens, and a prescription placed under it inherits a confidence
it has not earned.

Surface resealed after the rebase onto #288; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lamemustafa added a commit that referenced this pull request Sep 12, 2026
* Bind source entities to masters deterministically in the shared crate

Every failure across four document-import engagements was binding a
document's entities to the target book's masters, never reading the
document. Bridge was growing two answers to it: an MCP-private prefix
matcher that named one near-miss candidate as `exact_live_spelling`, and
a desktop screen that correctly ranks nothing but narrows nothing either.

Add `bridge_tally_core::master_binding` as the single contract both
surfaces consume, per ADR 0016. It matches an identifier embedded in a
master name before the name itself, binds only where a rule is unique on
both sides, and never resolves a near-miss: it reports candidates with
the rule that surfaced each, and no score. An empty catalogue is a typed
refusal rather than a report full of "missing".

Deletes `master_match` and `master_key` from agent_import and moves
unicode-normalization down a layer with them. The write gate is
unchanged: build_import_xml and the approved-post recheck still admit
byte-exact names only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Retain observed master names verbatim, and characterize the rules at scale

Self-review found a real defect: a catalog name was trimmed at the
boundary, so a bound row reported a spelling the book does not contain.
The write gate compares byte-exact against Tally's own name, so that
would have refused with no explanation. Observed names are now retained
verbatim; only source names are trimmed. Names differing solely in
surrounding whitespace are an ambiguity, not a refused catalog.

Adds a characterization suite over one fabricated 200-master book with
the recorded naming pathologies. The assertion that matters is that no
entity binds to a master a human would not have chosen; the counts are
pinned underneath so loosening a threshold has to move a number.

The mutation sweep was checked against two positive controls rather than
trusted for passing: resolving a near-miss to its first candidate trips
it, and binding a lone candidate does not. Both results are recorded in
the test, so it is read as "no mutation reaches the wrong master" and
not as "no rule change can loosen binding".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Narrow the existing-ledger list to what binding could defend

The preparation screen listed every ledger in the company for every
source entry, so the operator read the whole catalogue once per line.
#288 already returns a per-entry binding; nothing drew it.

Each entry's list now opens with the ledger binding matched, or with the
candidates it could not choose between, followed by the complete
catalogue under its own heading. The full list stays reachable in every
case: this is a shortcut through it, never a filter on it.

Nothing is selected for the operator. A near miss says plainly that
nothing was chosen and reports a truncated candidate list truthfully,
and a capture carrying no bindings renders exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix the review findings, and the candidate quality live data exposed

Ten review findings, read against the tree rather than taken at face
value, and all reproduced. The severe one: bind_one selected Identifier
before ExactName, so a ledger carrying a number, requested byte-exactly,
returned match_state identifier while the write gate admits exact only.
Every ledger with a phone or account number in its name was permanently
unimportable — the exact population this contract was built for. When
the two signals agree it now reports the byte-level fact.

Also: neither side trims a name any more, so a trailing space cannot
claim byte equality it does not have; digits inside a mixed code are no
longer emitted as a standalone numeric; all admitted eight-digit date
orders are excluded, not just year-first; more identifiers than the
bound is refused rather than truncated, which could hide a conflict; the
source-entity bound now covers what the source parser admits and the
desktop states whether narrowing ran; desktop candidate bytes are capped
in aggregate; a binding refusal keeps the catalogue evidence it already
read; and master_binding.rs is sealed into the compatibility surface,
which needed a deliberate one-file cap raise since it now decides
admission outcomes.

The larger finding came from running the binder over 470 real ledger
names from all 16 synthetic books. Prefix matching offered a median of
40 candidates, 63% of the catalogue, and omitted the right master a
third of the time: a truncated name reaches a whole family, and an
alphabetically capped slice of DN Party 001..120 does not contain
DN Party 057. A prefix matching more masters than the cap is now counted
and deliberately not listed. Re-measured on the same names: where
candidates are listed the right master is present in 403 of 403 rows,
median list length 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Scope fallback assignment to the report it came from

The remaining review finding, and the one I had triaged but not fixed.
FallbackBinding::assign took an EntityBinding detached from its report
plus any catalog, so a stock-item binding could be parked against a
ledger catalog and the result carried no provenance for anything
downstream to detect.

Assignment is now a method on BindingReport taking an index into its own
entities, so an entity from another report cannot be named at all, the
catalog class is checked, and the binding carries its class forward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address the four findings the fixes themselves generated

A fix is a change and generates its own findings; the re-review of the
previous commits raised four, all reproduced.

An unusable ledger name in a parsed draft was skipped while the response
still claimed a complete narrowing pass, so the rows that vanished were
exactly the ones worth looking at; the pass is now reported unavailable.
The reported candidate total took the larger of the suppressed family and
the retained candidates, which under-reports when they are different
masters; it is now their union. An identifier hint reached extraction
without the bound applied to every other name. And the operator workflow
in docs/agent/README.md still told readers to correct only near_miss
rows, which now leaves a bound-but-not-exact row refused at the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep period labels out of code identifiers, and drop a quadratic scan

Two further findings from the re-review, both reproduced.

`Purchases FY25` and `Sales FY25` both yielded the code identifier
`FY25`, so identifier-first matching bound the source to whichever
existed before it ever compared the names. A fiscal-period label
identifies a period, not a party, and is now excluded by shape; the
minimum code length also rises from four to six, since a four-character
mixed token is weak evidence of identity and the failure mode here is
money against the wrong party. Re-measured against the same 470 live
ledger names: no change to the distribution, so the tightening costs
nothing observed.

Candidate collection recounted every prefix from the start of the name,
making it quadratic in a field the source parser lets reach 4 KiB. It
now carries the character count forward in one pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Seed identifier-bearing ledgers, and fix what they immediately exposed

No book on the instance carried an embedded identifier: across 470 live
ledger names from all 16 loaded companies, zero yielded a numeric
identifier and exactly one a code identifier. The rule that separates
this from fuzzy matching had no live coverage at all.

Ten `MB ` ledgers now exist in BRIDGE CORPUS OPENING, parented to
Suspense A/c so no receivable, payable or ageing measurement moves, and
documented in TEST_CORPUS.md section 9 with the import method and the
company-choice reasoning. BRIDGE PROBE B SANDBOX was rejected as the
target despite the manufacturing precedent: it shares a GUID with a
second loaded company and Bridge's own reads refuse it as
company_identity_ambiguous.

Within minutes the pair sharing one identifier exposed a defect no
fabricated fixture had produced. A byte-exact request for a ledger whose
embedded number is shared with another was refused as
IdentifierConflict, making that ledger permanently unimportable, since
the write gate admits exact only. Byte equality with an observed master
name is now decisive: it names exactly one master, and an ambiguous
identifier does not undermine it. Only a decisive identifier pointing
elsewhere still outranks an exact name, and that stays a reported
conflict.

Re-measured over 485 live names, 2,330 cases: identifier binds 3 -> 11,
every uppercase mutation now binds, and where candidates are listed the
right master is present in 434 of 434 rows at a median list length of 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Recognize period labels by shape, and bound the report at its source

Two findings from the latest re-review, both reproduced.

Enumerating the period shapes that must not become identifiers was a
losing game: FY25 was fixed, then APR2025 and 2025Q1 were still binding
two unrelated ledgers that merely share a period. The rule is now a
shape — every run in the token is a short alphabetic marker or a number
reading as a year or small ordinal, at most three runs — and a code
identifier additionally needs eight alphanumerics, three digits and two
letters. Requiring real length is the part that does not depend on
having thought of every label. Measured against 485 live ledger names,
exactly one yields a code identifier at all, and it still does.

The aggregate candidate budget was applied to the consumer's copy, so
the report's own clones were already allocated by then; capping the copy
bounded only the copy. The budget now lives in bind() and is spent in
entity order, and the desktop's second budget is deleted as redundant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Make the comparison key an explicit contract point

The voucher-presence lane needs the same fold for voucher numbers and
voucher-type names that master names use, and is exposing this function
crate-wide to get it. That is the right call — a second, subtly
different normaliser is the divergence ADR 0016 exists to end, and it
would diverge silently, agreeing on every name tested by hand and
differing on the punctuation nobody thinks to try.

Records that obligation at the function, and its corollary: changing
what this folds changes every consumer's notion of sameness at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Say what an undiscriminable source line actually means

The narrowing copy predated the state the live measurement made
necessary. For a source name that reaches a family of ledgers and tells
none of them apart, the report now lists no candidates and reports the
count — so the screen would have rendered "0 possible ledgers are listed
first", a count of nothing, over the case that matters most.

That state now says what it means: the line matches N existing ledgers
and separates none of them, so none is listed; use a fuller source name
or pick from the full list. No misleading "Possible" heading appears
over an empty group, and the whole catalogue stays reachable as before.

Also surfaces bindings_state: when the narrowing pass could not run, the
toolbar says so rather than letting every row look merely unnarrowed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Name the channel a parked identity must travel in

A client-supplied REMOTEID is not readable back — Tally overwrites the
attribute with its own value (IMPLEMENTATION_GUIDE.md §3.3a, fourth
property, verified). Nothing here uses it, but the doc on
unresolved_identity said only that the identity is retained "for later
reallocation" without naming the channel, and the obvious wrong choice
fails silently: an amount parked with its identity in a write-only field
is unreallocatable, and nothing about the write says so.

Says narration, and says why. Surfaced by the voucher-presence lane,
which had to correct three claims of its own after reading the
reference's §9.3 heading as the general case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Say in the producer's contract that an empty candidate list is three facts

candidates can be empty because nothing resembles the name, because a
family resembles it and none is separable, or because the list was cut —
and those mean opposite things to whoever decides what to do next. The
disambiguators are reason, candidate_count and candidates_truncated, and
reading the empty vector alone is wrong in two cases out of three.

Recorded here rather than left to each consumer because it has already
been got wrong twice by different lanes: the preparation screen rendered
"0 possible ledgers are listed first" over a family of 120, and the
voucher-presence contract needed a paired test to stop its rule
collapsing into "no candidates means unknown". Same defect class this
ADR was written against — a refusal whose neighbouring value reads as an
answer.

Also records why this is a doc and not a type. An enum of Listed /
Truncated / Withheld / None is the stronger fix and the one P2 asks for,
but it is breaking, a stacked consumer already depends on
candidates_truncated as a predicate and holds the boundary with tests,
and forcing that rework mid-review trades an improvement for a
regression risk. Revisit once both have merged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Follow Tally's own rule for master sameness, and type the candidate listing

Five items in one round, because review reopens once either way and the
marginal cost of the rest once it is reopened is small.

Tally's master-name matching is measured, not guessable:
IMPLEMENTATION_GUIDE.md §3.3b found it case-insensitive AND
separator-insensitive — a hyphen matches a space — and otherwise exact.
The binder was stricter, which is not the safe direction it looks like:
it refused names Tally accepts, and `X - Y` is a common ledger
convention. A separate master_identity_key follows §3.3b and stops where
Tally stops; `AND` for `&`, a missing suffix word and a singular for a
plural still refuse. It is separate from comparison_key rather than a
widening of it, because that one is shared with voucher numbers and
voucher-type names and §3.3b says nothing about those. Measured live: 16
of 16 hyphenated masters now bind from the spelling Tally itself
accepts, where all 16 were near-misses before.

Candidates becomes None | Listed | Truncated | Withheld. An empty vector
was three different facts and a consumer reading is_empty() was wrong in
two of them, a shape already got wrong twice by different lanes. Taken
before merge because the contract has not shipped and this is the
cheapest it will ever be; the consumer who pays for it measured thirty
lines and reported the change improves its code. The MCP result gains an
explicit listing discriminator, since a model is the caller that would
read an empty array as "no such ledger exists"; the desktop DTO stays
flat, where the screen already distinguishes the cases and is tested.

Also: FallbackBinding says reallocate with a Journal and never Alter or
Cancel, which §9.7 measured as duplicating with the target untouched
while reporting success; the ADR records that identifier coverage is
bimodal by client (42%, 0%, 0%, 0%) so the rule is a first-pass check
and never a primary key; and BindingStatus says what a Bound does not
establish — not that the master still exists, not that the requested
name may be written, not that it is right in business terms, and no
authority at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Close the six findings the contract change generated

Three were the same shape recurring: an identifier built from something
that identifies a period rather than a party.

A token carrying letters now never yields a standalone numeric, whether
or not it qualified as a code — `Part A12345678` was reaching an
unrelated `Bank 12345678` through the one-letter gap the code test
rejects. Period labels are recognised by their numbers rather than their
words, which catches `SEPTEMBER2025` and `2025QUARTER1` that no cap on
the alphabetic run ever would: a month name can be any length, a year
cannot. And a fiscal range is excluded before its digits are fused,
since `2025-2026` strips to an eight-digit run no calendar reading
rejects.

Fallback assignment now checks catalog provenance, not just class: two
ledger catalogs are both Ledger, and a fallback drawn from the one the
report never saw names a master that was never a candidate. Candidate
collection selects by index and clones only what it retains, instead of
cloning every match before the cap and the budget discard most of it.

ADR 0016 quoted thresholds this module stopped using two rounds ago, and
it is the contract two surfaces integrate against. Synced — and a test
now reads the ADR and asserts it quotes the live constants, so the next
drift fails rather than waiting to be noticed. Verified against a
positive control: changing a constant without the document fails it. It
also caught a false positive of its own on first run, which was the
detector being too strict about `(10%)` rather than the ADR being wrong.

Live re-measure over 485 names, 2,330 cases: 434 of 434 listed rows
still contain the right master, median listed length 2, no wrong binds.
Identifier binds 11 -> 8 with bound and unbound totals unchanged: three
mutations that had bound to themselves through a leaked numeric now bind
by name instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Stop a weaker rule reinstating a withheld family, and make Listed serializable

Both reproduced by execution before fixing, and both re-verified against
the same reproduction after.

A token shared across a name family IS the family. Where the catalog is
large enough that the token stays under the common-token threshold — 30
rows among 330 is 9% — the shared-token pass re-offered exactly the rows
the prefix pass had withheld, restoring the arbitrary capped slice that
withholding exists to prevent. Measured: the case reported near_miss
with 25 of 30 listed and the intended master outside the slice; it now
reports no_discriminating_candidate with the family counted and none
listed. Weaker rules now skip the withheld set; decisive rules still
reach a family member on its own evidence, because the identifier or the
whole key is what separates that one from its siblings.

Candidates::Listed was a newtype variant carrying a sequence, and under
Serde's internally tagged representation a tag cannot be merged into a
sequence — so serializing the MOST COMMON unresolved result failed at
runtime while the other three variants succeeded. Now a struct variant.
No test caught it because none had ever serialized an Unresolved, only a
Bound; every variant now round-trips in a test.

Live re-measure unchanged: 434 of 434 listed rows contain the right
master, median listed length 2, no wrong binds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Test periods on the raw token, and let conflicting identifiers outrank a name

Both reproduced by execution before fixing, both re-verified after, and
both fixes are smaller than what they replace.

`FY2025-26` canonicalizes to `FY202526`, whose six-digit run reads as no
period at all, so the label became a Code identifier and `Purchases
FY2025-26` bound to a sole `Sales FY2025-26`. The period test now runs
on the raw token and splits on the separators operators actually write,
so `FY2025` and `26` stay legible as what they are. That subsumes
is_year_range, which is deleted: one period test where there were two,
covering ranges the numeric path caught and the code path did not.

Two identifier hints selecting two other masters, with the source name
byte-matching a third, bound the name and silently discarded the
conflict. A byte-exact name survives an identifier that is merely
shared — the ambiguous set still contains the master the name spells —
but not identifiers that all point elsewhere. The predicate is now that
one sentence rather than three conditions, and the report offers every
master the evidence reached, so the operator sees the disagreement
rather than one side of it.

Live re-measure unchanged: 434 of 434 listed rows contain the right
master, median listed length 2, no wrong binds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Treat a non-ASCII name as a name, and a mask as identifying nothing

Two wrong binds, both reproduced before fixing and both re-verified
after, and both the same class one level down: an identifier built from
something that is not identity.

The letter guard was ASCII-only, so `पार्टी12345678` read as digits
standing alone and bound a party to an unrelated `Bank 12345678`. The
observed books carry Devanagari, Tamil and Bengali ledger names, so this
was reachable on the corpus this PR already reads. The guard is now
Unicode alphabetic.

`XXXXX1234X` cleared every length and composition test — ten characters,
six letters, four digits, no period — while carrying only a last four
that any number of parties share, so two unrelated ledgers with the same
mask bound to each other. A token whose letters are a single repeated
character is a mask; an identity-bearing code has distinct letters.

TEST_CORPUS.md §9 recorded live counts without the confidence marker
AGENTS.md requires, so the seeding, the coverage counts, the rule's live
behaviour and the general safety claim are now separated into VERIFIED,
VERIFIED, PARTIAL and UNVERIFIED with the scope of each. The strongest
claim in that section was never the one a reader would have taken from
it.

Live re-measure unchanged: 434 of 434 listed rows contain the right
master, no wrong binds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep provenance per identifier rather than flattening it

Third finding on the same predicate, so the fix is the structural one
rather than a fourth condition.

The predicate asked whether the *union* of matched masters contains the
byte-exact one. That answers the shared case correctly — one number on
two masters, where the name separates them — and the mixed case wrongly:
`ACME 11111111` with a hint reaching `BETA 22222222` has the exact
master in the union because its own number is one of the identifiers,
while a second identifier plainly disagrees.

Flattening identifier-to-master provenance into one set discarded the
only fact that separates those two, and they need opposite answers. The
match is now kept per identifier, and a byte-exact name is outranked
when any single identifier reached somewhere else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Reject mask punctuation before extracting a numeric identifier

`is_masked` guards the code branch, where a mask spelled with letters is
caught by the letter test anyway. A mask spelled with punctuation never
reaches it: in the numeric branch every non-digit is an ordinary
delimiter, so `********12345678` split cleanly and offered its visible
suffix as though it were the whole account, binding two unrelated
ledgers that share it.

A value written with mask punctuation is partial by construction, so
what it exposes is a suffix and not the number. Ordinary punctuation
around a whole number is untouched — `(5550001001)` and `5550001-002`
still bind — because a fix that rejected all punctuation would have been
quietly worse than the bug.

Fourth finding in the same family: an identifier built from something
that is not identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Stop tearing Indic names apart at their joins

Tokenisation split on "not alphanumeric", and `char::is_alphanumeric` is
false for a Devanagari virama — the halant that joins consonants — and
false for a nukta. So Indic ledger names were cut at exactly the
character that holds a word together: `राय एण्ड सन्स` yielded one usable
token instead of three, `ट्रेडर्स` became a fragment, and a Tamil name
lost its tail. Those names are in the books this binder already reads.

The separator set is now defined positively — whitespace, and ASCII
punctuation — so an ASCII class decides only questions about ASCII
characters and everything outside it is word content, marks and joiners
included. That keeps the rule right for scripts nobody here has tested.

This only ever degraded candidate quality, never caused a wrong bind:
shared tokens surface candidates and do not decide. But it degraded it
precisely for the names an Indian firm would actually use.

Verified against a positive control: with the old predicate the new test
finds no candidates at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Mask across tokens, and split a range on the dashes we already fold

Two more of the same family, both small.

A mask and the digits it hides are often written apart: `**** 12345678`
is the same statement as `********12345678`, and reading tokens
independently lost the relationship, so the attached form was rejected
while the separated one bound. Extraction now carries whether the
preceding token was a mask.

The period boundary split on ASCII `-` and `/` only, while the
comparison key already folds the Unicode dash variants — so `FY2025–26`
fused to `FY202526` and became an identity-bearing code where
`FY2025-26` had failed closed. Both now admit the same set, named once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Pin prefer-exact, refuse-ambiguous, never-pick

`TALLY_PROTOCOL_REFERENCE.md` §9.4b states the requirement the fold's
callers must meet, after a reviewer there observed that "compare on the
canonical form" alone permits an implementation weaker than the one in
the tree: it says nothing about what to do when two masters collapse
together, and nothing measured says which one Tally would choose.

The binder already behaves correctly — verified by execution against
`Alpha-Beta` and `Alpha Beta` in one catalogue, which collapse under the
three verified transformations. Byte equality outranks the shared key,
and with no exact spelling to prefer the collapse is reported with both
masters offered rather than resolved to one.

Pinning it because a fold that returns the first match is the failure
mode, and correct-by-accident and correct-by-test look identical until
someone simplifies the ambiguity branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Pin the absent-master direction of the fold

prefer-exact and refuse-ambiguous cover the case where two masters
collapse onto a request. They say nothing about the opposite one: the
requested master is absent and a *different* ledger collapses onto the
request. There is one candidate and no ambiguity to refuse, so the
safeguard never fires.

Verified by execution in both directions — `A & B` against a book
holding only `AB`, and the reverse — plus the three §3.3b rejections
read from the absent side. All refuse, because `&` stays significant in
this fold. The near-miss cases still offer the collapsing ledger as a
candidate, which is what a looser-than-measured comparison is allowed to
do: suggest for a human, never resolve.

Uniqueness under a fold is only as meaningful as the fold, and nothing
in the tree was holding `&` significant on purpose until now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): close four identifier holes and prove the path live

Six review findings, each reproduced against the code before it was changed.

Four are one family: an identifier was manufactured from something that
identifies nothing, and a sole live master carrying the same manufactured value
then bound to it.

- A mask spelled with letters lost its mask when written a space from its
  digits: `XXXX 12345678` yielded the suffix as a whole account number. Both
  mask spellings now suppress the token that follows, for both identifier
  shapes.
- A token holding non-ASCII letters shed them during canonicalization and
  yielded a code the name never contained, so a party name in another script
  reached an unrelated bank ledger while its ASCII spelling did not. Such a
  token now yields no code.
- A fiscal range written without its separator — `FY202425`, `FY20242025` —
  arrived as one run that every length test missed and passed as a code. A year
  followed by a year now reads as the period it is.
- The identifier-hint bound was checked on the deduplicated set, so repeated
  hints folded to one identifier and the bound never fired while every hint had
  already been scanned and copied. Bounded as the hints arrive.

The remaining two are the type-level guarantee and the evidence.

- `BindingReport` no longer derives `Deserialize`. `catalog.fingerprint()` is
  public, so a restored report could forge the provenance `assign_fallback`
  checks and draw a fallback for an entity the binder never emitted. `bind` is
  now the only way to obtain one, and forging is a compile error.
- The binder had never run through the surface that ships it. It has now:
  the branch's own `bridge_mcp` binary driven over a real MCP session against
  licensed TallyPrime 7.1, reading a live catalogue over the wire and returning
  the binder's report. Ten source names reach every decision the binder makes.
  Recorded in TEST_CORPUS.md §9 with what it does and does not establish.

That slice also showed the fold binding on a transformation
TALLY_PROTOCOL_REFERENCE.md §9.4b marks UNVERIFIED. ADR 0016 §3 claimed the
fold "stops exactly where Tally stops"; it does not, and four of its steps are
Bridge policy rather than measured Tally behaviour. The citation is corrected
to §9.4b in both the ADR and the code, the width is argued on its own terms
with the two guards that carry it, and the open trade is recorded rather than
settled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): only a measured fold may resolve a master name

A seventh review finding, and the one I had raised upstream myself an hour
earlier from the other direction: the fold that decided a binding resolved on
four transformations TALLY_PROTOCOL_REFERENCE.md §9.4b marks UNVERIFIED — the
reverse hyphen direction, collapsed whitespace runs, leading whitespace, and the
Unicode dash variants.

A module whose first principle is that a near-miss never auto-resolves was
auto-resolving on evidence that does not support resolving. §9.4b's own remedy
is that a looser fold may *suggest* while only the measured transformations
settle anything, so there are now two folds:

- `verified_fold` resolves, and implements only the three §9.4b measured: ASCII
  case, one trailing space, and a source space matching a master hyphen.
- `master_identity_key` suggests, keeps every unverified transformation, and
  everything it reaches is offered as a `NormalizedEqual` candidate.

Direction needed the index, not a narrower string function. The measurement is a
source *space* against a master *hyphen*, and a shared key is symmetric. So a
master answers to its own spelling and to its hyphens-as-spaces, while a source
answers only to its own: a source hyphen finds no master space, a source space
still finds a master hyphen. Two masters answering to one key remain an
ambiguity, which is what Tally's behaviour implies.

The cost is real and is now written down rather than discovered later. `X - Y`
is a common ledger convention — six of seventeen hyphenated names in the
observed books — and reaching it from `X Y` needs the measured hyphen step *and*
a whitespace run collapsed, so it no longer resolves. On the fabricated mutation
book 420 of 995 mutations bind where most once did.

What makes that a trade and not a loss is asserted case by case rather than as a
percentage: every mutation the wide fold would have resolved is still shown as a
candidate carrying the right master. Of the 278 that reach neither, the wide
fold would have bound zero — they were already out of reach. Six tests fail if
the narrow index is reverted.

ADR 0016 §3 is rewritten around the two folds and records the cost. The live
slice in TEST_CORPUS.md §9 was run before this change; its third row showed the
old bind and is marked PENDING a re-run, the other nine being unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): canonical equivalence is measured wrong, not unverified

The narrowing held the resolving fold to §9.4b's three verified transformations
but left `.nfc()` in it, and that step is worse than the four this PR already
removed: it is not unproven, it is proven wrong.

`tally-matches-master-names-by-exact-codepoint` measured it on 2026-08-19. A
voucher naming a UI-created NFC ledger in its canonically equivalent NFD
spelling was rejected — `EXCEPTIONS=1`, `LINEERROR`, ledger does not exist —
while the NFC spelling created it. Tally stores the bytes it was given and
matches on exact codepoints, so the two spellings are different masters to
Tally, and folding them together resolves a source name onto a master Tally
itself keeps apart.

NFC stays in the wide fold, where it can only suggest: the NFD source now
reaches a near-miss carrying the NFC master as its sole candidate.

The reason it survived is worth more than the fix. I audited this fold against
§9.4b twice and never counted `.nfc()`, because canonical equivalence reads as
*decoding* rather than folding — the same characters, spelled two ways, nothing
an operator could type differently on purpose. Every row in §9.4b's table is a
judgement step, so an auditor working from the table finds nothing wrong with
normalising first. ADR 0016 §3 now says so in general terms: a step that reads
like decoding deserves the same evidence as a step that reads like folding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): a delimiter does not unmask, and ASCII is not the alphabet

Two review findings, both extensions of fixes in this PR, plus a third instance
the second one led to.

**A separator between a mask and its suffix cleared the mask.** Tokens split on
whitespace, so `XXXX - 12345678` processes `-` as a token of its own, and the
unconditional `previous_was_mask = masked_here` cleared the state before the
digits arrived. The assignment was written as "remember whether this token was a
mask" when the question it answers is "is the next token still hidden?" — and
those differ for exactly the class of token an operator inserts without
thinking. A token with no alphanumeric content is now a delimiter: it neither
sets nor clears the mask. An ordinary word still clears it, or nothing
downstream of a mask could identify anything again.

**The non-ASCII guard was too narrow, in both branches.** It tested
`char::is_alphabetic`, which is false for a Devanagari digit, so
`AB<devanagari digits>12345678` still canonicalized to `AB12345678` and reached
a sole live `Bank AB12345678`. The numeric branch had the identical hole and no
thread named it: `12345678<devanagari digits>` carries no alphabetic character
at all, so it passed the letters guard and the digit-run split emitted
`12345678`, reaching an unrelated `Bank 12345678`.

So the guard is no longer a list of exclusions. The admitted set is positive — a
token yields an identifier only if it is ASCII apart from the dash variants this
module already folds as separators — and it guards both branches from one place.
The question is not which scripts exist, a list this module has now got wrong
twice, but which characters canonicalization is entitled to drop.

That is the second time here an ASCII-shaped class silently decided a non-ASCII
question; the first was `char::is_alphanumeric` being false for the Devanagari
virama, which tore Indic ledger names apart at their joins. ADR 0016 §2 states
both rules positively rather than as script exclusions.

Three mutation controls: restoring the unconditional mask assignment, narrowing
the guard back to `is_alphabetic`, and dropping it from the numeric branch alone
each fail a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(source-draft): read the refusal reason instead of flattening it

Three review findings, all of them the screen claiming more than the DTO says.

**`identifier` is not "a number".** The binder extracts two shapes — a numeric
run and an alphanumeric code — and the DTO carries only the generic basis, so
for any ledger identified by a registration or part code the sentence was false.
Carrying the kind through was the other remedy and I did not take it: it would
change one word and nothing the operator does, and a field that cannot be acted
on differently is a field that later gets read for more than it says.

**Every refusal got the same near-miss sentence.** An identifier/name conflict
is not a weak match — both sides are strong and they disagree — and flattening
it told the operator the opposite of what happened, in the one case where they
have something to act on. Identifier/name conflict, identifier conflict and name
ambiguity now each get their own lead; the name-ambiguity copy says that nothing
measured says which one Tally would pick, which is why nothing was chosen.

**An empty candidate list means two opposite things.** A family the name cannot
separate is fixed by a fuller source name; a report that ran out of room on
earlier rows is not fixed by anything written in this row, so the old copy sent
the operator to rewrite a name that was never the problem.
`UnboundReason::NoDiscriminatingCandidate` already marks the first case, so the
branch keys on the reason rather than on an empty list. `candidates_truncated`
cannot carry this: it is `Candidates::is_incomplete()`, true for both Truncated
and Withheld.

The live-family measurement the third comment rests on is no longer only a
comment — `docs/tally/TEST_CORPUS.md` §9.1 now records the counts, the cause and
the scope, and the comment cites it.

Three mutation controls; each of the three fixes has a test that fails without
it, and the two that replace misleading copy also assert the old copy is absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): the trailing space was directional too, and three more

Eight review findings. Three are correctness, and one of them is the same
mistake this PR had already made once.

**The trailing space was stripped on both sides.** The hyphen was moved onto the
master side of the index precisely because §9.4b's separator result is
directional; the trailing space is the row directly below it, directional for
the same reason, and it stayed in the shared fold. A master spelled `Sales `
therefore resolved from a clean `Sales`, which was never sent. `verified_fold`
no longer strips anything; `source_binding_key` drops one trailing space,
because the source is the side that was measured. A test I wrote asserted the
wrong direction, and one test's premise disappeared entirely — `Alpha Traders`
and `Alpha Traders ` no longer collide, so the ambiguity case is rewritten
around case-only siblings, which do.

**A mask was a list of glyphs.** `........12345678` and `____ 12345678` were not
masks, so the suffix left as an account number. Enumerating glyphs is the losing
move this module has now made three times — period spellings, then scripts, now
mask characters. A run of three or more of one repeated non-alphanumeric is a
mask by shape; the four glyphs stay as an additional test, since a lone `*` is
a mask and shape alone would stop seeing it.

**A date fused into a code-shaped token was still a code.** `is_plausible_date`
guarded the numeric branch only, and `is_period`'s eight-digit case admits a
year followed by a year, which `20250911` is not. `DATED20250911` bound two
unrelated ledgers to each other.

The rest bound resources and correct documents.

- The catalog had no aggregate byte bound: 20,000 names of 16,384 characters
  satisfies both documented limits and is 327 MB before the indexes are built.
  Accumulated as the iterator is consumed, so it refuses before retaining the
  next name.
- `collect_candidates` is memoized per distinct source key and identifier reach,
  capped at 1,024 entries so a draft of distinct names cannot trade the stall
  for the memory the bound above exists to prevent. The mutation control caught
  my first memo key omitting the identifier reach, which would have handed one
  entity another's candidates.
- ADR 0016 §3 claimed `SourceEntity` trims source names. It does not, and never
  did; the section now describes what the constructor actually does.
- `bindings_state: "complete"` means the pass ran, not that everything bound.
  The TypeScript comment said the opposite, which is the failure this module
  exists to prevent, one layer up.
- TEST_CORPUS §9's identifier-coverage row contradicted its own evidence in the
  direction that made the rule look better supported. The absence is of the
  numeric shape only; one code identifier was observed.

`TALLY_PROTOCOL_REFERENCE.md` §9.4c records the live-catalogue family behaviour
and the rule it implies, with §9.1 of the corpus keeping the counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(source-draft): name both shapes of identifier conflict, and cite 9.4c

Two review findings.

`master_binding_identifier_conflict` is reached two ways: one identifier
carried by several ledgers, and several identifiers each reaching a different
ledger. The copy described only the first, so in the second case it sent the
operator hunting for a duplicate that does not exist. It now says the
identifiers do not agree on one ledger, and names both shapes.

The live-catalogue family behaviour is now recorded as
`TALLY_PROTOCOL_REFERENCE.md` §9.4c, with the counts staying in `TEST_CORPUS.md`
§9.1, and this comment cites the reference rather than the corpus. I had argued
the measurement belonged only in the corpus because it is Bridge's behaviour
rather than Tally's; the part I had wrong is that "real catalogues carry
families a partial name cannot separate" is an observation about live books, and
any client matching a supplied name against a read catalogue meets it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): the memo missed the path it was written for

The memo added for repeated source names went into `unresolved_status`, which
the identifier-conflict and name-ambiguity paths reach. An ordinary near miss
does not: its `None` arm called `collect_candidates` directly. So the memo
covered two paths and missed the one the cost was reported against — a
truncated name against a prefix family matches nothing exactly, by identifier
or by the narrow fold, so every entity took the unmemoized route.

My own test for that fix used exactly that case and passed, because it asserted
the answer rather than the work. A memo that is never consulted returns correct
answers all day.

`remembered_candidates` is now the only way to reach the search, so a call site
cannot forget the memo. And `collect_candidates` increments a test-only counter,
so the repeated-name test asserts the search ran **once** for forty rows rather
than that the forty answers agree. Reverting the near-miss path to the direct
call now gives 40 against an expected 1 — the assertion is an instrument, which
is what the first version was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(corpus): the narrowed fold's live row, re-run and confirmed

The end-to-end slice was recorded with one row PENDING: the separator row had
been run before the resolving fold was narrowed, and showed the bind the
narrowing removed. The lab endpoint went down before it could be repeated.

It is back, and the re-run confirms it. Same ten names, same company, same
catalogue — `catalogue_evidence_sha256` 0767077c on both runs, so the book did
not move underneath the comparison. `MB-PILOT-ALPHA-(5550001001)` now returns a
near-miss carrying `MB PILOT ALPHA (5550001001)` as its sole candidate under
`normalized_equal`, and reports the code it could not resolve on. The other nine
rows returned identically.

That is the two-fold split in a single row, against a real instance: the looser
fold still reaches the master and no longer answers for it. §9.2 is VERIFIED for
all ten rows at 330bd696, and the confidence table no longer carries the
exception.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): measure the SKU, and widen the fold to what it folds

A reviewer pointed out that §9.4b is scoped to **Edit Log 7.0 Educational** and
marks licensed TallyPrime UNVERIFIED, while nothing in this module carried a
profile to enforce that with — so every normalized bind rested on a measurement
from a different SKU. True, and nobody had said it.

The remedy they offered was to gate the fold on a qualified profile. With the
owner's authorisation I measured the profile instead, using §9.4b's own method:
import a voucher naming a folded spelling, and let Tally answer. Twelve
variants against licensed 7.1 silver, then the **day book read back** to record
which master each voucher actually posted against — one reported ALTERED where
CREATED was expected, so the counters alone would have been a guess. All eight
created vouchers were deleted by REMOTEID and the day read back empty.

Licensed 7.1 folds **more** than the Educational scope allowed anyone to claim.
Space, `-` and `/` are one separator in both directions; internal runs collapse;
surrounding whitespace is ignored; ASCII case folds. An en dash, an underscore,
`AND` for `&` and an NFD spelling are all rejected.

So the narrowing earlier in this PR was right against §9.4b and wrong against
the SKU. The resolving fold is symmetric again, one key per side, and the
asymmetric index it needed is deleted. 600 of 995 mutations bind, against 420
under the narrow fold, and `X - Y` resolves again.

**The two rules that matter are negative and neither is guessable.** An en dash
and an underscore look like separators and are not, so the set is written out
rather than described — a mutation replacing it with `is_ascii_punctuation()`
fails two tests. Canonical equivalence stays refused, re-confirmed on this SKU.

Recorded as TALLY_PROTOCOL_REFERENCE.md §9.4d with the method, the deletion and
the scope. §9.4b's rows now **cite** it rather than absorb it: those rows are
about a different instance, and folding a licensed result into them would
silently widen a measurement nobody repeated there. Its one row grouping
underscore, en dash and `/` is split, because on this SKU they do not agree.

ADR 0016 §3 records being wrong in both directions. The lesson is neither fold
less nor fold more: the scope line of an inherited measurement is part of the
measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(source-draft): keep the reason when the listing or the refusal goes

Three review findings, and the first is this PR reintroducing its own defect one
branch over.

**A refusal reason did not survive the listing being dropped.** The
budget-exhaustion branch added in the previous commit returned only the budget
sentence, so an identifier/name conflict arriving with no room to list its
candidates read as a plain "ran out of room" — exactly the hiding the
neighbouring branch had just been fixed to stop. It now leads with the reason
and then says the listing would not fit.

**"Case and separators" is a class, and Tally does not fold it as one.**
§9.4d measured which separators fold on the release this writes to: space,
hyphen and slash do; an en dash and an underscore do not. Naming the class sent
an operator hunting for variants that played no part in the refusal, and it is
the generalisation §9.4d exists to stop. The copy now names what was measured.

**The refusal summary outlived the refusal.** After the operator chose a
target it kept rendering "nothing is chosen", directly beside the line telling
them the target was re-read and bound. The reason still matters after a choice —
an identifier and a name pointing at different ledgers is grounds to check it —
so it survives in the past tense and the guidance that no longer applies goes.
That split the "nothing is chosen" clause out of the reason leads, which is
where it belonged anyway.

Three mutation controls, one per fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): four holes the previous round's own fixes opened

Six review findings on 35adca8b. Two of the three P1s are in code this PR added
last round, which is the useful part of the record: a cost optimisation is a
change to what the code *knows*, not only to what it spends.

**A large holder set stopped answering the question it was asked.** Skipping the
expansion past `MAX_CANDIDATES_PER_ENTITY` also skipped the push to
`per_identifier`, so `identifier_points_elsewhere` went blind and a byte-exact
name bound while its hint pointed entirely elsewhere. Size-dependent: correct at
three holders, silently wrong at twenty-six. Membership of the exact master is
one test rather than a materialization, so the bound survives and the invariant
stops depending on family size. Both sides of the boundary are asserted.

**A date range evaded the date guard by being fused first.**
`20250911-20250912` strips to sixteen digits, which is no length
`is_plausible_date` recognizes, and `is_period` reads neither half as a year
range. Two guards, each blind from its own side. The components are now checked
before the separator is removed — a guard belongs on the value the rule is
about, not on whatever the pipeline happens to be holding.

**The memo's protection depended on source order.** Enough distinct cheap misses
at the head of a draft filled the entry cap, and the expensive repeated key
behind them was then never cached — the stall it exists to prevent, reachable by
reordering the same rows. `bind` now counts key multiplicity in one borrowing
pass before binding anything and caches only what a second row will ask for
again, which removes the ordering rather than making it cheaper to recover from.

**A stock item resolved on a fold measured for ledgers.** §9.4d reads "ledgers,
on licensed 7.1" in a scope paragraph this PR wrote, and the remeasurement is
what made the fold wide enough for that to matter. A folded stock-item name now
suggests and does not resolve; byte equality is unaffected. ADR 0016 §1 no
longer says the rules are identical for both classes, because only the
identifier rules are.

Two `REMOTEID` comments now cite TALLY_PROTOCOL_REFERENCE.md §9.3 rather than
the implementation guide: the behaviour is a gateway observation and the
reference is where those live.

Four mutation controls, one per fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): close four holes, and label what §9.4d measured

The previous round's fixes each opened a smaller hole behind them.

- Identifier canonicalisation dropped `-` and `/` but nothing else, so two
  codes differing only in unmeasured punctuation collapsed into one. It now
  removes only the two separators the gateway was measured to fold.
- A family larger than the cap was skipped without recording its size, so a
  withheld family reported no count at all. It now reports how many share the
  identifier even when the holders are not expanded.
- A hint pointing away from an exact name was only honoured below the cap.
  An exact name no longer outranks a hint at any family size.
- A stock item could resolve on a fold. It may now suggest on one and
  nothing more, and the test asserts the reason, not only the outcome.

Three of the round's earlier tests asserted the outcome where the fix was
about the work done to reach it, and passed with the fix reverted. Two
test-only counters (`HOLDER_EXPANSIONS`, `CANDIDATE_SEARCHES`) now let them
assert the work.

§9.4d recorded `AND` for `&` as rejected, but the probe that produced the row
sent a name with `AND CO` appended, against a master carrying no `&`. That
measures an added suffix. The substitution was re-measured against
`Profit & Loss A/c`, a reserved ledger present in every company, and the
section now carries four accurately-labelled rows and says which of them it
measured. The verdict was unchanged; the label was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): say what §9.4d's probe counted, and cite the guide by name

The method paragraph still described the first run alone — twelve variants,
eight vouchers deleted — after the re-measurement added six more. It now
counts both runs.

`the_master_fold_stops_where_tally_stops` cited "§3.3b" without naming the
document that carries it, which is `IMPLEMENTATION_GUIDE.md`, not this
reference; the same three rows now also point at §9.4d, where two of them
were re-measured on licensed 7.1.

Compatibility surface resealed for the changed pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(source-draft): one selection predicate, and real evidence on the capture fixture

Three findings from the same review, all about a claim being wider than what
produced it.

**The summary took any saved ledger for a current-session choice.** The
`<select>` and its status line both use `catalogSelections[key] === entry.ledger`
— a value re-read against the capture in front of the operator — while the
summary used `Boolean(entry.ledger)`, which is true for a target saved in an
earlier session and never re-read. So beside a saved value the control showed
nothing selected, the status line called it unverified, and the summary said
the operator had chosen, dropping the candidate count and the guidance they
needed to judge it. The predicate is computed once now and all three read it.

**The capture fixture carried zeroed evidence.** A file named for a capture
claimed a zero response digest, a zero request digest, zero bytes and a zero
source digest. The retained capture has a provenance sidecar carrying the
genuine request and response digests and the response size, and the source
document has a real digest of its own; all four are now in the fixture and
asserted by the producer test against that sidecar, so they cannot go back to
placeholders while the file still calls itself a capture. `capture_id` has no
captured counterpart — it is minted per read — and stays a synthetic UUID,
now shaped like the others.

**The NFD comment restated external behaviour without citing it.** It now
cites §9.4d, which sent an NFD spelling at an NFC master on licensed 7.1 and
watched Tally reject it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(source-draft): drive the grouped control at a live catalogue's size

The capture is nine ledgers and the fabricated catalogues are three. Both are
real shapes; neither is a real size, and size is the one dimension no capture
can supply, because no lab company carries thousands of ledgers.

Two thousand and one targets, one of them matched: the narrowed group still
leads with the single match, and the full list is still 2,001 long. Slicing
the full list to 200 fails this and nothing else in the file — every other
test here works at three targets, where truncation is invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): four more, three of them behind last round's own fixes

**A byte is not a unit of evidence.** The code threshold was `canonical.len()`,
which is UTF-8 bytes. An admitted dash variant is three of them, so `AB–123`
measured eight against a constant named `MIN_CODE_IDENTIFIER_CHARS` and became
decisive on five alphanumerics — while its ASCII twin `AB-123` reduces to
`AB123` and is refused. The same code identified or did not depending on which
dash the document carried. It now counts letters and digits, which also closes
the padding shape bytes could never see. The upper bound stays in bytes: it
bounds what the index stores, and a byte cap admits no more characters than it
says.

**One family's size is not the size of their union.** A skipped family of
thirty beside an identifier reaching a thirty-first reported thirty. The listed
masters not in the skipped family are provably disjoint from it, so they are
added — a bisection per master, no set built. It stays a lower bound, and the
field now says so rather than describing the implementation.

**The memo's repeat count read half its key.** Entries are keyed by source key
*and* the masters the identifiers reached, but repetition was counted on the
key alone, so 1,024 hint variants of one name filled a cache nothing asks for
twice and the pair that did repeat behind them could not be inserted — the
source-order defect again, through the back door. Counted on `(key,
identifiers)` now, still borrowed.

**Only one side of an untrusted pair was bounded.** The catalog constructor has
an aggregate byte budget because count and length bounds do not bound their
product; the source side had none, and 40,000 individually valid entities are
two and a half gigabytes of names before this module clones one. 16 MiB,
checked at `bind`, counting both retained folds beside the name.

Each fix has a test whose control is the fix reverted, and each control fails
its own test alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(source-draft): carry the candidate state, do not rebuild it downstream

There are two ways a candidate listing comes back empty beside a nonzero
count, and they call for opposite things from the operator: a family the
binder declined to slice, where a fuller source name helps, and a report that
ran out of room, where nothing the operator writes will. The screen told them
apart by the refusal reason, which named only one of the two withheld shapes —
so a family withheld under `IdentifierConflict` reached the budget sentence and
said the report had run out of room when it had not.

Adding the second reason to that check would be the same defect with a longer
condition. The state travels instead:

- `Candidates::listing()` returns the state as the word `#[serde(tag =
  "listing")]` already puts on the wire, and a test serializes every variant
  and asserts the accessor against the tag, so the DTO reader and the JSON
  reader cannot come to disagree.
- The DTO's `candidates_truncated` is replaced by `candidate_listing`, not
  joined by it: the boolean was `is_incomplete()`, true for both withheld and
  truncated, which was the conflation itself.
- The screen branches on the carried word. The refusal reason still chooses
  the lead sentence, which is what it is for.

The agent surface already derived its own flag from `listing` and was correct
throughout; only the desktop projection flattened the state and then tried to
rebuild it.

Also reseals the compatibility surface against the final bytes. The previous
reseal ran before `cargo fmt`, which rewrote a pinned file underneath it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(source-draft): cover the second withheld shape, and stop calling it truncated

The mechanical rewrite of `candidates_truncated` into `candidate_listing`
turned the `no_discriminating_candidate` fixture into `"truncated"` — which is
the exact conflation this change removes, reproduced by the change itself.

`a family withheld under a different reason is not reported as a full report`
is the shape the review found: a family withheld under `identifier_conflict`,
which the reason-based inference did not recognise and which therefore reached
the budget sentence. Reverting the branch to the reason check fails it and
nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(source-draft): a choice settles what the operator wants, not a disagreement

The summary was suppressed whenever a `bound_target` merely existed beside a
current-session selection. So choosing B where the capture defended A left
nothing on screen saying the two differed — while the line beside it said the
target had been re-read and bound, which reads as agreement.

The comparison is against the target now, not against whether one exists.
Identical: silence, which is what the suppression is for. Different: the
binding's own ledger is named, and the operator is told their choice stands.

Both halves are tested, because a suppression and a message are each other's
control: returning `null` unconditionally fails the disagreement test, and a
message on agreement would fail the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): the separator cuts both ways, and the memo excluded its own reason

**A separator reveals a date and hides two.** The date guard ran on the
canonical only, where removing `-` turns `2025-09-11` into a recognisable
`20250911`. It also turns `DATED20250911-20250912` into one sixteen-digit run
that reads as no date at all, and `is_period` does not see it either, because
its eight-digit case admits a year followed by a year and `0911` is neither. A
date range identified, and a period label is the single thing two unrelated
masters most reliably share. The guard now runs on both spellings; neither
subsumes the other. What is *not* fixed is written above `carries_plausible_date`:
a partly separated range still escapes all three guards, and the obvious
generalisation was considered and rejected, because refusing every sixteen-digit
account number containing a date-shaped window throws away the identifiers this
module exists to use.

**The memo refused to hold the searches it existed for.** `worth_holding`
excluded results larger than the candidate cap, reasoning that a large result
costs what recomputing it costs — which assumes it is recomputed once. A name
reaching a large family through shared tokens, repeated across a draft, is the
case where it is recomputed per row, and it was exactly the case excluded.
`collect_candidates` now sorts into the caller's order and truncates, so every
result is holdable and the per-row allocation of a whole family is gone;
`found` is still the full union, so the count is unchanged. The ordering is one
function used by both the truncation and the sort, because a disagreement
between them would drop a candidate silently. The now-vacuous size test is
removed rather than left as a trap.

**The candidates came from a different index than the reason.** `NameAmbiguous`
is decided on the resolving fold and the list was built from the wide one,
which is not always coarser: `master_identity_key` replaces `-` but not `/`, so
`AB/CD` and `AB CD` are one master to `verified_fold` and two to it. The
operator was shown an ambiguity with a complete-looking list of one. The
resolving-key holders are offered too — correct however the two folds relate,
where widening the wide fold would fix one example and leave the list assembled
from the wrong index.

**A request refusable from its own arguments spent two live reads first.** The
tool schema admits names the core refuses. Parsing now happens before
`verified_company` and before the catalogue read, and `master_report` takes
parsed entities so the ordering is not a convention to remember.

Each fix has a test whose control is the fix reverted. The last one's control
is the endpoint: nothing is listening on it, so a read attempted before parsing
comes back as a failure to connect rather than `master_name_unsafe`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: label conservative candidate counts

* docs: specify candidate count precision for consumers

* fix: memoize repeated derived candidate keys

* perf: bisect large identifier holder membership

* chore: reseal catalogue membership correction

* docs(agent): describe candidate count precision

* fix(ui): keep withheld identifier conflicts visible

* fix(master-binding): separate count precision from listing completeness, and key the memo on both folds

**The memo key named one fold while the search read two.** `collect_candidates`
reads `entity.binding_key` as well as `entity.key` — I added that on #288 so the
masters which *caused* a name ambiguity are the ones listed — and I did not
extend the key that guards it. `AB-CD` and `AB–CD` share a wide key, because
`comparison_key` maps every dash variant to `-`, and differ under the resolving
fold, because §9.4d sent an en dash at a live master and Tally rejected it. The
gateway's own refusal is what makes the keys diverge, and the memo crossed them:
one spelling could be served the other's candidates.

The key now carries both folds and the fingerprint hashes both. Only the key
half is observable — a coarser fingerprint cannot serve a wrong result, it can
only re-admit entries the pre-count exists to exclude, which needs a thousand
colliding keys against nine dash variants — so the fingerprint is right by
construction and its doc says so rather than a test implying otherwise.

**Count precision and listing completeness are different questions.**
`count_is_lower_bound()` derived the first from the se…
lamemustafa added a commit that referenced this pull request Sep 15, 2026
* Bind source entities to masters deterministically in the shared crate

Every failure across four document-import engagements was binding a
document's entities to the target book's masters, never reading the
document. Bridge was growing two answers to it: an MCP-private prefix
matcher that named one near-miss candidate as `exact_live_spelling`, and
a desktop screen that correctly ranks nothing but narrows nothing either.

Add `bridge_tally_core::master_binding` as the single contract both
surfaces consume, per ADR 0016. It matches an identifier embedded in a
master name before the name itself, binds only where a rule is unique on
both sides, and never resolves a near-miss: it reports candidates with
the rule that surfaced each, and no score. An empty catalogue is a typed
refusal rather than a report full of "missing".

Deletes `master_match` and `master_key` from agent_import and moves
unicode-normalization down a layer with them. The write gate is
unchanged: build_import_xml and the approved-post recheck still admit
byte-exact names only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Retain observed master names verbatim, and characterize the rules at scale

Self-review found a real defect: a catalog name was trimmed at the
boundary, so a bound row reported a spelling the book does not contain.
The write gate compares byte-exact against Tally's own name, so that
would have refused with no explanation. Observed names are now retained
verbatim; only source names are trimmed. Names differing solely in
surrounding whitespace are an ambiguity, not a refused catalog.

Adds a characterization suite over one fabricated 200-master book with
the recorded naming pathologies. The assertion that matters is that no
entity binds to a master a human would not have chosen; the counts are
pinned underneath so loosening a threshold has to move a number.

The mutation sweep was checked against two positive controls rather than
trusted for passing: resolving a near-miss to its first candidate trips
it, and binding a lone candidate does not. Both results are recorded in
the test, so it is read as "no mutation reaches the wrong master" and
not as "no rule change can loosen binding".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Narrow the existing-ledger list to what binding could defend

The preparation screen listed every ledger in the company for every
source entry, so the operator read the whole catalogue once per line.
#288 already returns a per-entry binding; nothing drew it.

Each entry's list now opens with the ledger binding matched, or with the
candidates it could not choose between, followed by the complete
catalogue under its own heading. The full list stays reachable in every
case: this is a shortcut through it, never a filter on it.

Nothing is selected for the operator. A near miss says plainly that
nothing was chosen and reports a truncated candidate list truthfully,
and a capture carrying no bindings renders exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix the review findings, and the candidate quality live data exposed

Ten review findings, read against the tree rather than taken at face
value, and all reproduced. The severe one: bind_one selected Identifier
before ExactName, so a ledger carrying a number, requested byte-exactly,
returned match_state identifier while the write gate admits exact only.
Every ledger with a phone or account number in its name was permanently
unimportable — the exact population this contract was built for. When
the two signals agree it now reports the byte-level fact.

Also: neither side trims a name any more, so a trailing space cannot
claim byte equality it does not have; digits inside a mixed code are no
longer emitted as a standalone numeric; all admitted eight-digit date
orders are excluded, not just year-first; more identifiers than the
bound is refused rather than truncated, which could hide a conflict; the
source-entity bound now covers what the source parser admits and the
desktop states whether narrowing ran; desktop candidate bytes are capped
in aggregate; a binding refusal keeps the catalogue evidence it already
read; and master_binding.rs is sealed into the compatibility surface,
which needed a deliberate one-file cap raise since it now decides
admission outcomes.

The larger finding came from running the binder over 470 real ledger
names from all 16 synthetic books. Prefix matching offered a median of
40 candidates, 63% of the catalogue, and omitted the right master a
third of the time: a truncated name reaches a whole family, and an
alphabetically capped slice of DN Party 001..120 does not contain
DN Party 057. A prefix matching more masters than the cap is now counted
and deliberately not listed. Re-measured on the same names: where
candidates are listed the right master is present in 403 of 403 rows,
median list length 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Scope fallback assignment to the report it came from

The remaining review finding, and the one I had triaged but not fixed.
FallbackBinding::assign took an EntityBinding detached from its report
plus any catalog, so a stock-item binding could be parked against a
ledger catalog and the result carried no provenance for anything
downstream to detect.

Assignment is now a method on BindingReport taking an index into its own
entities, so an entity from another report cannot be named at all, the
catalog class is checked, and the binding carries its class forward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address the four findings the fixes themselves generated

A fix is a change and generates its own findings; the re-review of the
previous commits raised four, all reproduced.

An unusable ledger name in a parsed draft was skipped while the response
still claimed a complete narrowing pass, so the rows that vanished were
exactly the ones worth looking at; the pass is now reported unavailable.
The reported candidate total took the larger of the suppressed family and
the retained candidates, which under-reports when they are different
masters; it is now their union. An identifier hint reached extraction
without the bound applied to every other name. And the operator workflow
in docs/agent/README.md still told readers to correct only near_miss
rows, which now leaves a bound-but-not-exact row refused at the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep period labels out of code identifiers, and drop a quadratic scan

Two further findings from the re-review, both reproduced.

`Purchases FY25` and `Sales FY25` both yielded the code identifier
`FY25`, so identifier-first matching bound the source to whichever
existed before it ever compared the names. A fiscal-period label
identifies a period, not a party, and is now excluded by shape; the
minimum code length also rises from four to six, since a four-character
mixed token is weak evidence of identity and the failure mode here is
money against the wrong party. Re-measured against the same 470 live
ledger names: no change to the distribution, so the tightening costs
nothing observed.

Candidate collection recounted every prefix from the start of the name,
making it quadratic in a field the source parser lets reach 4 KiB. It
now carries the character count forward in one pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Seed identifier-bearing ledgers, and fix what they immediately exposed

No book on the instance carried an embedded identifier: across 470 live
ledger names from all 16 loaded companies, zero yielded a numeric
identifier and exactly one a code identifier. The rule that separates
this from fuzzy matching had no live coverage at all.

Ten `MB ` ledgers now exist in BRIDGE CORPUS OPENING, parented to
Suspense A/c so no receivable, payable or ageing measurement moves, and
documented in TEST_CORPUS.md section 9 with the import method and the
company-choice reasoning. BRIDGE PROBE B SANDBOX was rejected as the
target despite the manufacturing precedent: it shares a GUID with a
second loaded company and Bridge's own reads refuse it as
company_identity_ambiguous.

Within minutes the pair sharing one identifier exposed a defect no
fabricated fixture had produced. A byte-exact request for a ledger whose
embedded number is shared with another was refused as
IdentifierConflict, making that ledger permanently unimportable, since
the write gate admits exact only. Byte equality with an observed master
name is now decisive: it names exactly one master, and an ambiguous
identifier does not undermine it. Only a decisive identifier pointing
elsewhere still outranks an exact name, and that stays a reported
conflict.

Re-measured over 485 live names, 2,330 cases: identifier binds 3 -> 11,
every uppercase mutation now binds, and where candidates are listed the
right master is present in 434 of 434 rows at a median list length of 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Recognize period labels by shape, and bound the report at its source

Two findings from the latest re-review, both reproduced.

Enumerating the period shapes that must not become identifiers was a
losing game: FY25 was fixed, then APR2025 and 2025Q1 were still binding
two unrelated ledgers that merely share a period. The rule is now a
shape — every run in the token is a short alphabetic marker or a number
reading as a year or small ordinal, at most three runs — and a code
identifier additionally needs eight alphanumerics, three digits and two
letters. Requiring real length is the part that does not depend on
having thought of every label. Measured against 485 live ledger names,
exactly one yields a code identifier at all, and it still does.

The aggregate candidate budget was applied to the consumer's copy, so
the report's own clones were already allocated by then; capping the copy
bounded only the copy. The budget now lives in bind() and is spent in
entity order, and the desktop's second budget is deleted as redundant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Make the comparison key an explicit contract point

The voucher-presence lane needs the same fold for voucher numbers and
voucher-type names that master names use, and is exposing this function
crate-wide to get it. That is the right call — a second, subtly
different normaliser is the divergence ADR 0016 exists to end, and it
would diverge silently, agreeing on every name tested by hand and
differing on the punctuation nobody thinks to try.

Records that obligation at the function, and its corollary: changing
what this folds changes every consumer's notion of sameness at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Say what an undiscriminable source line actually means

The narrowing copy predated the state the live measurement made
necessary. For a source name that reaches a family of ledgers and tells
none of them apart, the report now lists no candidates and reports the
count — so the screen would have rendered "0 possible ledgers are listed
first", a count of nothing, over the case that matters most.

That state now says what it means: the line matches N existing ledgers
and separates none of them, so none is listed; use a fuller source name
or pick from the full list. No misleading "Possible" heading appears
over an empty group, and the whole catalogue stays reachable as before.

Also surfaces bindings_state: when the narrowing pass could not run, the
toolbar says so rather than letting every row look merely unnarrowed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Name the channel a parked identity must travel in

A client-supplied REMOTEID is not readable back — Tally overwrites the
attribute with its own value (IMPLEMENTATION_GUIDE.md §3.3a, fourth
property, verified). Nothing here uses it, but the doc on
unresolved_identity said only that the identity is retained "for later
reallocation" without naming the channel, and the obvious wrong choice
fails silently: an amount parked with its identity in a write-only field
is unreallocatable, and nothing about the write says so.

Says narration, and says why. Surfaced by the voucher-presence lane,
which had to correct three claims of its own after reading the
reference's §9.3 heading as the general case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Say in the producer's contract that an empty candidate list is three facts

candidates can be empty because nothing resembles the name, because a
family resembles it and none is separable, or because the list was cut —
and those mean opposite things to whoever decides what to do next. The
disambiguators are reason, candidate_count and candidates_truncated, and
reading the empty vector alone is wrong in two cases out of three.

Recorded here rather than left to each consumer because it has already
been got wrong twice by different lanes: the preparation screen rendered
"0 possible ledgers are listed first" over a family of 120, and the
voucher-presence contract needed a paired test to stop its rule
collapsing into "no candidates means unknown". Same defect class this
ADR was written against — a refusal whose neighbouring value reads as an
answer.

Also records why this is a doc and not a type. An enum of Listed /
Truncated / Withheld / None is the stronger fix and the one P2 asks for,
but it is breaking, a stacked consumer already depends on
candidates_truncated as a predicate and holds the boundary with tests,
and forcing that rework mid-review trades an improvement for a
regression risk. Revisit once both have merged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Follow Tally's own rule for master sameness, and type the candidate listing

Five items in one round, because review reopens once either way and the
marginal cost of the rest once it is reopened is small.

Tally's master-name matching is measured, not guessable:
IMPLEMENTATION_GUIDE.md §3.3b found it case-insensitive AND
separator-insensitive — a hyphen matches a space — and otherwise exact.
The binder was stricter, which is not the safe direction it looks like:
it refused names Tally accepts, and `X - Y` is a common ledger
convention. A separate master_identity_key follows §3.3b and stops where
Tally stops; `AND` for `&`, a missing suffix word and a singular for a
plural still refuse. It is separate from comparison_key rather than a
widening of it, because that one is shared with voucher numbers and
voucher-type names and §3.3b says nothing about those. Measured live: 16
of 16 hyphenated masters now bind from the spelling Tally itself
accepts, where all 16 were near-misses before.

Candidates becomes None | Listed | Truncated | Withheld. An empty vector
was three different facts and a consumer reading is_empty() was wrong in
two of them, a shape already got wrong twice by different lanes. Taken
before merge because the contract has not shipped and this is the
cheapest it will ever be; the consumer who pays for it measured thirty
lines and reported the change improves its code. The MCP result gains an
explicit listing discriminator, since a model is the caller that would
read an empty array as "no such ledger exists"; the desktop DTO stays
flat, where the screen already distinguishes the cases and is tested.

Also: FallbackBinding says reallocate with a Journal and never Alter or
Cancel, which §9.7 measured as duplicating with the target untouched
while reporting success; the ADR records that identifier coverage is
bimodal by client (42%, 0%, 0%, 0%) so the rule is a first-pass check
and never a primary key; and BindingStatus says what a Bound does not
establish — not that the master still exists, not that the requested
name may be written, not that it is right in business terms, and no
authority at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Close the six findings the contract change generated

Three were the same shape recurring: an identifier built from something
that identifies a period rather than a party.

A token carrying letters now never yields a standalone numeric, whether
or not it qualified as a code — `Part A12345678` was reaching an
unrelated `Bank 12345678` through the one-letter gap the code test
rejects. Period labels are recognised by their numbers rather than their
words, which catches `SEPTEMBER2025` and `2025QUARTER1` that no cap on
the alphabetic run ever would: a month name can be any length, a year
cannot. And a fiscal range is excluded before its digits are fused,
since `2025-2026` strips to an eight-digit run no calendar reading
rejects.

Fallback assignment now checks catalog provenance, not just class: two
ledger catalogs are both Ledger, and a fallback drawn from the one the
report never saw names a master that was never a candidate. Candidate
collection selects by index and clones only what it retains, instead of
cloning every match before the cap and the budget discard most of it.

ADR 0016 quoted thresholds this module stopped using two rounds ago, and
it is the contract two surfaces integrate against. Synced — and a test
now reads the ADR and asserts it quotes the live constants, so the next
drift fails rather than waiting to be noticed. Verified against a
positive control: changing a constant without the document fails it. It
also caught a false positive of its own on first run, which was the
detector being too strict about `(10%)` rather than the ADR being wrong.

Live re-measure over 485 names, 2,330 cases: 434 of 434 listed rows
still contain the right master, median listed length 2, no wrong binds.
Identifier binds 11 -> 8 with bound and unbound totals unchanged: three
mutations that had bound to themselves through a leaked numeric now bind
by name instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Stop a weaker rule reinstating a withheld family, and make Listed serializable

Both reproduced by execution before fixing, and both re-verified against
the same reproduction after.

A token shared across a name family IS the family. Where the catalog is
large enough that the token stays under the common-token threshold — 30
rows among 330 is 9% — the shared-token pass re-offered exactly the rows
the prefix pass had withheld, restoring the arbitrary capped slice that
withholding exists to prevent. Measured: the case reported near_miss
with 25 of 30 listed and the intended master outside the slice; it now
reports no_discriminating_candidate with the family counted and none
listed. Weaker rules now skip the withheld set; decisive rules still
reach a family member on its own evidence, because the identifier or the
whole key is what separates that one from its siblings.

Candidates::Listed was a newtype variant carrying a sequence, and under
Serde's internally tagged representation a tag cannot be merged into a
sequence — so serializing the MOST COMMON unresolved result failed at
runtime while the other three variants succeeded. Now a struct variant.
No test caught it because none had ever serialized an Unresolved, only a
Bound; every variant now round-trips in a test.

Live re-measure unchanged: 434 of 434 listed rows contain the right
master, median listed length 2, no wrong binds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Test periods on the raw token, and let conflicting identifiers outrank a name

Both reproduced by execution before fixing, both re-verified after, and
both fixes are smaller than what they replace.

`FY2025-26` canonicalizes to `FY202526`, whose six-digit run reads as no
period at all, so the label became a Code identifier and `Purchases
FY2025-26` bound to a sole `Sales FY2025-26`. The period test now runs
on the raw token and splits on the separators operators actually write,
so `FY2025` and `26` stay legible as what they are. That subsumes
is_year_range, which is deleted: one period test where there were two,
covering ranges the numeric path caught and the code path did not.

Two identifier hints selecting two other masters, with the source name
byte-matching a third, bound the name and silently discarded the
conflict. A byte-exact name survives an identifier that is merely
shared — the ambiguous set still contains the master the name spells —
but not identifiers that all point elsewhere. The predicate is now that
one sentence rather than three conditions, and the report offers every
master the evidence reached, so the operator sees the disagreement
rather than one side of it.

Live re-measure unchanged: 434 of 434 listed rows contain the right
master, median listed length 2, no wrong binds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Treat a non-ASCII name as a name, and a mask as identifying nothing

Two wrong binds, both reproduced before fixing and both re-verified
after, and both the same class one level down: an identifier built from
something that is not identity.

The letter guard was ASCII-only, so `पार्टी12345678` read as digits
standing alone and bound a party to an unrelated `Bank 12345678`. The
observed books carry Devanagari, Tamil and Bengali ledger names, so this
was reachable on the corpus this PR already reads. The guard is now
Unicode alphabetic.

`XXXXX1234X` cleared every length and composition test — ten characters,
six letters, four digits, no period — while carrying only a last four
that any number of parties share, so two unrelated ledgers with the same
mask bound to each other. A token whose letters are a single repeated
character is a mask; an identity-bearing code has distinct letters.

TEST_CORPUS.md §9 recorded live counts without the confidence marker
AGENTS.md requires, so the seeding, the coverage counts, the rule's live
behaviour and the general safety claim are now separated into VERIFIED,
VERIFIED, PARTIAL and UNVERIFIED with the scope of each. The strongest
claim in that section was never the one a reader would have taken from
it.

Live re-measure unchanged: 434 of 434 listed rows contain the right
master, no wrong binds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep provenance per identifier rather than flattening it

Third finding on the same predicate, so the fix is the structural one
rather than a fourth condition.

The predicate asked whether the *union* of matched masters contains the
byte-exact one. That answers the shared case correctly — one number on
two masters, where the name separates them — and the mixed case wrongly:
`ACME 11111111` with a hint reaching `BETA 22222222` has the exact
master in the union because its own number is one of the identifiers,
while a second identifier plainly disagrees.

Flattening identifier-to-master provenance into one set discarded the
only fact that separates those two, and they need opposite answers. The
match is now kept per identifier, and a byte-exact name is outranked
when any single identifier reached somewhere else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Reject mask punctuation before extracting a numeric identifier

`is_masked` guards the code branch, where a mask spelled with letters is
caught by the letter test anyway. A mask spelled with punctuation never
reaches it: in the numeric branch every non-digit is an ordinary
delimiter, so `********12345678` split cleanly and offered its visible
suffix as though it were the whole account, binding two unrelated
ledgers that share it.

A value written with mask punctuation is partial by construction, so
what it exposes is a suffix and not the number. Ordinary punctuation
around a whole number is untouched — `(5550001001)` and `5550001-002`
still bind — because a fix that rejected all punctuation would have been
quietly worse than the bug.

Fourth finding in the same family: an identifier built from something
that is not identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Stop tearing Indic names apart at their joins

Tokenisation split on "not alphanumeric", and `char::is_alphanumeric` is
false for a Devanagari virama — the halant that joins consonants — and
false for a nukta. So Indic ledger names were cut at exactly the
character that holds a word together: `राय एण्ड सन्स` yielded one usable
token instead of three, `ट्रेडर्स` became a fragment, and a Tamil name
lost its tail. Those names are in the books this binder already reads.

The separator set is now defined positively — whitespace, and ASCII
punctuation — so an ASCII class decides only questions about ASCII
characters and everything outside it is word content, marks and joiners
included. That keeps the rule right for scripts nobody here has tested.

This only ever degraded candidate quality, never caused a wrong bind:
shared tokens surface candidates and do not decide. But it degraded it
precisely for the names an Indian firm would actually use.

Verified against a positive control: with the old predicate the new test
finds no candidates at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Mask across tokens, and split a range on the dashes we already fold

Two more of the same family, both small.

A mask and the digits it hides are often written apart: `**** 12345678`
is the same statement as `********12345678`, and reading tokens
independently lost the relationship, so the attached form was rejected
while the separated one bound. Extraction now carries whether the
preceding token was a mask.

The period boundary split on ASCII `-` and `/` only, while the
comparison key already folds the Unicode dash variants — so `FY2025–26`
fused to `FY202526` and became an identity-bearing code where
`FY2025-26` had failed closed. Both now admit the same set, named once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Pin prefer-exact, refuse-ambiguous, never-pick

`TALLY_PROTOCOL_REFERENCE.md` §9.4b states the requirement the fold's
callers must meet, after a reviewer there observed that "compare on the
canonical form" alone permits an implementation weaker than the one in
the tree: it says nothing about what to do when two masters collapse
together, and nothing measured says which one Tally would choose.

The binder already behaves correctly — verified by execution against
`Alpha-Beta` and `Alpha Beta` in one catalogue, which collapse under the
three verified transformations. Byte equality outranks the shared key,
and with no exact spelling to prefer the collapse is reported with both
masters offered rather than resolved to one.

Pinning it because a fold that returns the first match is the failure
mode, and correct-by-accident and correct-by-test look identical until
someone simplifies the ambiguity branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Pin the absent-master direction of the fold

prefer-exact and refuse-ambiguous cover the case where two masters
collapse onto a request. They say nothing about the opposite one: the
requested master is absent and a *different* ledger collapses onto the
request. There is one candidate and no ambiguity to refuse, so the
safeguard never fires.

Verified by execution in both directions — `A & B` against a book
holding only `AB`, and the reverse — plus the three §3.3b rejections
read from the absent side. All refuse, because `&` stays significant in
this fold. The near-miss cases still offer the collapsing ledger as a
candidate, which is what a looser-than-measured comparison is allowed to
do: suggest for a human, never resolve.

Uniqueness under a fold is only as meaningful as the fold, and nothing
in the tree was holding `&` significant on purpose until now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): close four identifier holes and prove the path live

Six review findings, each reproduced against the code before it was changed.

Four are one family: an identifier was manufactured from something that
identifies nothing, and a sole live master carrying the same manufactured value
then bound to it.

- A mask spelled with letters lost its mask when written a space from its
  digits: `XXXX 12345678` yielded the suffix as a whole account number. Both
  mask spellings now suppress the token that follows, for both identifier
  shapes.
- A token holding non-ASCII letters shed them during canonicalization and
  yielded a code the name never contained, so a party name in another script
  reached an unrelated bank ledger while its ASCII spelling did not. Such a
  token now yields no code.
- A fiscal range written without its separator — `FY202425`, `FY20242025` —
  arrived as one run that every length test missed and passed as a code. A year
  followed by a year now reads as the period it is.
- The identifier-hint bound was checked on the deduplicated set, so repeated
  hints folded to one identifier and the bound never fired while every hint had
  already been scanned and copied. Bounded as the hints arrive.

The remaining two are the type-level guarantee and the evidence.

- `BindingReport` no longer derives `Deserialize`. `catalog.fingerprint()` is
  public, so a restored report could forge the provenance `assign_fallback`
  checks and draw a fallback for an entity the binder never emitted. `bind` is
  now the only way to obtain one, and forging is a compile error.
- The binder had never run through the surface that ships it. It has now:
  the branch's own `bridge_mcp` binary driven over a real MCP session against
  licensed TallyPrime 7.1, reading a live catalogue over the wire and returning
  the binder's report. Ten source names reach every decision the binder makes.
  Recorded in TEST_CORPUS.md §9 with what it does and does not establish.

That slice also showed the fold binding on a transformation
TALLY_PROTOCOL_REFERENCE.md §9.4b marks UNVERIFIED. ADR 0016 §3 claimed the
fold "stops exactly where Tally stops"; it does not, and four of its steps are
Bridge policy rather than measured Tally behaviour. The citation is corrected
to §9.4b in both the ADR and the code, the width is argued on its own terms
with the two guards that carry it, and the open trade is recorded rather than
settled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): only a measured fold may resolve a master name

A seventh review finding, and the one I had raised upstream myself an hour
earlier from the other direction: the fold that decided a binding resolved on
four transformations TALLY_PROTOCOL_REFERENCE.md §9.4b marks UNVERIFIED — the
reverse hyphen direction, collapsed whitespace runs, leading whitespace, and the
Unicode dash variants.

A module whose first principle is that a near-miss never auto-resolves was
auto-resolving on evidence that does not support resolving. §9.4b's own remedy
is that a looser fold may *suggest* while only the measured transformations
settle anything, so there are now two folds:

- `verified_fold` resolves, and implements only the three §9.4b measured: ASCII
  case, one trailing space, and a source space matching a master hyphen.
- `master_identity_key` suggests, keeps every unverified transformation, and
  everything it reaches is offered as a `NormalizedEqual` candidate.

Direction needed the index, not a narrower string function. The measurement is a
source *space* against a master *hyphen*, and a shared key is symmetric. So a
master answers to its own spelling and to its hyphens-as-spaces, while a source
answers only to its own: a source hyphen finds no master space, a source space
still finds a master hyphen. Two masters answering to one key remain an
ambiguity, which is what Tally's behaviour implies.

The cost is real and is now written down rather than discovered later. `X - Y`
is a common ledger convention — six of seventeen hyphenated names in the
observed books — and reaching it from `X Y` needs the measured hyphen step *and*
a whitespace run collapsed, so it no longer resolves. On the fabricated mutation
book 420 of 995 mutations bind where most once did.

What makes that a trade and not a loss is asserted case by case rather than as a
percentage: every mutation the wide fold would have resolved is still shown as a
candidate carrying the right master. Of the 278 that reach neither, the wide
fold would have bound zero — they were already out of reach. Six tests fail if
the narrow index is reverted.

ADR 0016 §3 is rewritten around the two folds and records the cost. The live
slice in TEST_CORPUS.md §9 was run before this change; its third row showed the
old bind and is marked PENDING a re-run, the other nine being unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): canonical equivalence is measured wrong, not unverified

The narrowing held the resolving fold to §9.4b's three verified transformations
but left `.nfc()` in it, and that step is worse than the four this PR already
removed: it is not unproven, it is proven wrong.

`tally-matches-master-names-by-exact-codepoint` measured it on 2026-08-19. A
voucher naming a UI-created NFC ledger in its canonically equivalent NFD
spelling was rejected — `EXCEPTIONS=1`, `LINEERROR`, ledger does not exist —
while the NFC spelling created it. Tally stores the bytes it was given and
matches on exact codepoints, so the two spellings are different masters to
Tally, and folding them together resolves a source name onto a master Tally
itself keeps apart.

NFC stays in the wide fold, where it can only suggest: the NFD source now
reaches a near-miss carrying the NFC master as its sole candidate.

The reason it survived is worth more than the fix. I audited this fold against
§9.4b twice and never counted `.nfc()`, because canonical equivalence reads as
*decoding* rather than folding — the same characters, spelled two ways, nothing
an operator could type differently on purpose. Every row in §9.4b's table is a
judgement step, so an auditor working from the table finds nothing wrong with
normalising first. ADR 0016 §3 now says so in general terms: a step that reads
like decoding deserves the same evidence as a step that reads like folding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): a delimiter does not unmask, and ASCII is not the alphabet

Two review findings, both extensions of fixes in this PR, plus a third instance
the second one led to.

**A separator between a mask and its suffix cleared the mask.** Tokens split on
whitespace, so `XXXX - 12345678` processes `-` as a token of its own, and the
unconditional `previous_was_mask = masked_here` cleared the state before the
digits arrived. The assignment was written as "remember whether this token was a
mask" when the question it answers is "is the next token still hidden?" — and
those differ for exactly the class of token an operator inserts without
thinking. A token with no alphanumeric content is now a delimiter: it neither
sets nor clears the mask. An ordinary word still clears it, or nothing
downstream of a mask could identify anything again.

**The non-ASCII guard was too narrow, in both branches.** It tested
`char::is_alphabetic`, which is false for a Devanagari digit, so
`AB<devanagari digits>12345678` still canonicalized to `AB12345678` and reached
a sole live `Bank AB12345678`. The numeric branch had the identical hole and no
thread named it: `12345678<devanagari digits>` carries no alphabetic character
at all, so it passed the letters guard and the digit-run split emitted
`12345678`, reaching an unrelated `Bank 12345678`.

So the guard is no longer a list of exclusions. The admitted set is positive — a
token yields an identifier only if it is ASCII apart from the dash variants this
module already folds as separators — and it guards both branches from one place.
The question is not which scripts exist, a list this module has now got wrong
twice, but which characters canonicalization is entitled to drop.

That is the second time here an ASCII-shaped class silently decided a non-ASCII
question; the first was `char::is_alphanumeric` being false for the Devanagari
virama, which tore Indic ledger names apart at their joins. ADR 0016 §2 states
both rules positively rather than as script exclusions.

Three mutation controls: restoring the unconditional mask assignment, narrowing
the guard back to `is_alphabetic`, and dropping it from the numeric branch alone
each fail a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(source-draft): read the refusal reason instead of flattening it

Three review findings, all of them the screen claiming more than the DTO says.

**`identifier` is not "a number".** The binder extracts two shapes — a numeric
run and an alphanumeric code — and the DTO carries only the generic basis, so
for any ledger identified by a registration or part code the sentence was false.
Carrying the kind through was the other remedy and I did not take it: it would
change one word and nothing the operator does, and a field that cannot be acted
on differently is a field that later gets read for more than it says.

**Every refusal got the same near-miss sentence.** An identifier/name conflict
is not a weak match — both sides are strong and they disagree — and flattening
it told the operator the opposite of what happened, in the one case where they
have something to act on. Identifier/name conflict, identifier conflict and name
ambiguity now each get their own lead; the name-ambiguity copy says that nothing
measured says which one Tally would pick, which is why nothing was chosen.

**An empty candidate list means two opposite things.** A family the name cannot
separate is fixed by a fuller source name; a report that ran out of room on
earlier rows is not fixed by anything written in this row, so the old copy sent
the operator to rewrite a name that was never the problem.
`UnboundReason::NoDiscriminatingCandidate` already marks the first case, so the
branch keys on the reason rather than on an empty list. `candidates_truncated`
cannot carry this: it is `Candidates::is_incomplete()`, true for both Truncated
and Withheld.

The live-family measurement the third comment rests on is no longer only a
comment — `docs/tally/TEST_CORPUS.md` §9.1 now records the counts, the cause and
the scope, and the comment cites it.

Three mutation controls; each of the three fixes has a test that fails without
it, and the two that replace misleading copy also assert the old copy is absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): the trailing space was directional too, and three more

Eight review findings. Three are correctness, and one of them is the same
mistake this PR had already made once.

**The trailing space was stripped on both sides.** The hyphen was moved onto the
master side of the index precisely because §9.4b's separator result is
directional; the trailing space is the row directly below it, directional for
the same reason, and it stayed in the shared fold. A master spelled `Sales `
therefore resolved from a clean `Sales`, which was never sent. `verified_fold`
no longer strips anything; `source_binding_key` drops one trailing space,
because the source is the side that was measured. A test I wrote asserted the
wrong direction, and one test's premise disappeared entirely — `Alpha Traders`
and `Alpha Traders ` no longer collide, so the ambiguity case is rewritten
around case-only siblings, which do.

**A mask was a list of glyphs.** `........12345678` and `____ 12345678` were not
masks, so the suffix left as an account number. Enumerating glyphs is the losing
move this module has now made three times — period spellings, then scripts, now
mask characters. A run of three or more of one repeated non-alphanumeric is a
mask by shape; the four glyphs stay as an additional test, since a lone `*` is
a mask and shape alone would stop seeing it.

**A date fused into a code-shaped token was still a code.** `is_plausible_date`
guarded the numeric branch only, and `is_period`'s eight-digit case admits a
year followed by a year, which `20250911` is not. `DATED20250911` bound two
unrelated ledgers to each other.

The rest bound resources and correct documents.

- The catalog had no aggregate byte bound: 20,000 names of 16,384 characters
  satisfies both documented limits and is 327 MB before the indexes are built.
  Accumulated as the iterator is consumed, so it refuses before retaining the
  next name.
- `collect_candidates` is memoized per distinct source key and identifier reach,
  capped at 1,024 entries so a draft of distinct names cannot trade the stall
  for the memory the bound above exists to prevent. The mutation control caught
  my first memo key omitting the identifier reach, which would have handed one
  entity another's candidates.
- ADR 0016 §3 claimed `SourceEntity` trims source names. It does not, and never
  did; the section now describes what the constructor actually does.
- `bindings_state: "complete"` means the pass ran, not that everything bound.
  The TypeScript comment said the opposite, which is the failure this module
  exists to prevent, one layer up.
- TEST_CORPUS §9's identifier-coverage row contradicted its own evidence in the
  direction that made the rule look better supported. The absence is of the
  numeric shape only; one code identifier was observed.

`TALLY_PROTOCOL_REFERENCE.md` §9.4c records the live-catalogue family behaviour
and the rule it implies, with §9.1 of the corpus keeping the counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(source-draft): name both shapes of identifier conflict, and cite 9.4c

Two review findings.

`master_binding_identifier_conflict` is reached two ways: one identifier
carried by several ledgers, and several identifiers each reaching a different
ledger. The copy described only the first, so in the second case it sent the
operator hunting for a duplicate that does not exist. It now says the
identifiers do not agree on one ledger, and names both shapes.

The live-catalogue family behaviour is now recorded as
`TALLY_PROTOCOL_REFERENCE.md` §9.4c, with the counts staying in `TEST_CORPUS.md`
§9.1, and this comment cites the reference rather than the corpus. I had argued
the measurement belonged only in the corpus because it is Bridge's behaviour
rather than Tally's; the part I had wrong is that "real catalogues carry
families a partial name cannot separate" is an observation about live books, and
any client matching a supplied name against a read catalogue meets it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): the memo missed the path it was written for

The memo added for repeated source names went into `unresolved_status`, which
the identifier-conflict and name-ambiguity paths reach. An ordinary near miss
does not: its `None` arm called `collect_candidates` directly. So the memo
covered two paths and missed the one the cost was reported against — a
truncated name against a prefix family matches nothing exactly, by identifier
or by the narrow fold, so every entity took the unmemoized route.

My own test for that fix used exactly that case and passed, because it asserted
the answer rather than the work. A memo that is never consulted returns correct
answers all day.

`remembered_candidates` is now the only way to reach the search, so a call site
cannot forget the memo. And `collect_candidates` increments a test-only counter,
so the repeated-name test asserts the search ran **once** for forty rows rather
than that the forty answers agree. Reverting the near-miss path to the direct
call now gives 40 against an expected 1 — the assertion is an instrument, which
is what the first version was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(corpus): the narrowed fold's live row, re-run and confirmed

The end-to-end slice was recorded with one row PENDING: the separator row had
been run before the resolving fold was narrowed, and showed the bind the
narrowing removed. The lab endpoint went down before it could be repeated.

It is back, and the re-run confirms it. Same ten names, same company, same
catalogue — `catalogue_evidence_sha256` 0767077c on both runs, so the book did
not move underneath the comparison. `MB-PILOT-ALPHA-(5550001001)` now returns a
near-miss carrying `MB PILOT ALPHA (5550001001)` as its sole candidate under
`normalized_equal`, and reports the code it could not resolve on. The other nine
rows returned identically.

That is the two-fold split in a single row, against a real instance: the looser
fold still reaches the master and no longer answers for it. §9.2 is VERIFIED for
all ten rows at 330bd696, and the confidence table no longer carries the
exception.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): measure the SKU, and widen the fold to what it folds

A reviewer pointed out that §9.4b is scoped to **Edit Log 7.0 Educational** and
marks licensed TallyPrime UNVERIFIED, while nothing in this module carried a
profile to enforce that with — so every normalized bind rested on a measurement
from a different SKU. True, and nobody had said it.

The remedy they offered was to gate the fold on a qualified profile. With the
owner's authorisation I measured the profile instead, using §9.4b's own method:
import a voucher naming a folded spelling, and let Tally answer. Twelve
variants against licensed 7.1 silver, then the **day book read back** to record
which master each voucher actually posted against — one reported ALTERED where
CREATED was expected, so the counters alone would have been a guess. All eight
created vouchers were deleted by REMOTEID and the day read back empty.

Licensed 7.1 folds **more** than the Educational scope allowed anyone to claim.
Space, `-` and `/` are one separator in both directions; internal runs collapse;
surrounding whitespace is ignored; ASCII case folds. An en dash, an underscore,
`AND` for `&` and an NFD spelling are all rejected.

So the narrowing earlier in this PR was right against §9.4b and wrong against
the SKU. The resolving fold is symmetric again, one key per side, and the
asymmetric index it needed is deleted. 600 of 995 mutations bind, against 420
under the narrow fold, and `X - Y` resolves again.

**The two rules that matter are negative and neither is guessable.** An en dash
and an underscore look like separators and are not, so the set is written out
rather than described — a mutation replacing it with `is_ascii_punctuation()`
fails two tests. Canonical equivalence stays refused, re-confirmed on this SKU.

Recorded as TALLY_PROTOCOL_REFERENCE.md §9.4d with the method, the deletion and
the scope. §9.4b's rows now **cite** it rather than absorb it: those rows are
about a different instance, and folding a licensed result into them would
silently widen a measurement nobody repeated there. Its one row grouping
underscore, en dash and `/` is split, because on this SKU they do not agree.

ADR 0016 §3 records being wrong in both directions. The lesson is neither fold
less nor fold more: the scope line of an inherited measurement is part of the
measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(source-draft): keep the reason when the listing or the refusal goes

Three review findings, and the first is this PR reintroducing its own defect one
branch over.

**A refusal reason did not survive the listing being dropped.** The
budget-exhaustion branch added in the previous commit returned only the budget
sentence, so an identifier/name conflict arriving with no room to list its
candidates read as a plain "ran out of room" — exactly the hiding the
neighbouring branch had just been fixed to stop. It now leads with the reason
and then says the listing would not fit.

**"Case and separators" is a class, and Tally does not fold it as one.**
§9.4d measured which separators fold on the release this writes to: space,
hyphen and slash do; an en dash and an underscore do not. Naming the class sent
an operator hunting for variants that played no part in the refusal, and it is
the generalisation §9.4d exists to stop. The copy now names what was measured.

**The refusal summary outlived the refusal.** After the operator chose a
target it kept rendering "nothing is chosen", directly beside the line telling
them the target was re-read and bound. The reason still matters after a choice —
an identifier and a name pointing at different ledgers is grounds to check it —
so it survives in the past tense and the guidance that no longer applies goes.
That split the "nothing is chosen" clause out of the reason leads, which is
where it belonged anyway.

Three mutation controls, one per fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): four holes the previous round's own fixes opened

Six review findings on 35adca8b. Two of the three P1s are in code this PR added
last round, which is the useful part of the record: a cost optimisation is a
change to what the code *knows*, not only to what it spends.

**A large holder set stopped answering the question it was asked.** Skipping the
expansion past `MAX_CANDIDATES_PER_ENTITY` also skipped the push to
`per_identifier`, so `identifier_points_elsewhere` went blind and a byte-exact
name bound while its hint pointed entirely elsewhere. Size-dependent: correct at
three holders, silently wrong at twenty-six. Membership of the exact master is
one test rather than a materialization, so the bound survives and the invariant
stops depending on family size. Both sides of the boundary are asserted.

**A date range evaded the date guard by being fused first.**
`20250911-20250912` strips to sixteen digits, which is no length
`is_plausible_date` recognizes, and `is_period` reads neither half as a year
range. Two guards, each blind from its own side. The components are now checked
before the separator is removed — a guard belongs on the value the rule is
about, not on whatever the pipeline happens to be holding.

**The memo's protection depended on source order.** Enough distinct cheap misses
at the head of a draft filled the entry cap, and the expensive repeated key
behind them was then never cached — the stall it exists to prevent, reachable by
reordering the same rows. `bind` now counts key multiplicity in one borrowing
pass before binding anything and caches only what a second row will ask for
again, which removes the ordering rather than making it cheaper to recover from.

**A stock item resolved on a fold measured for ledgers.** §9.4d reads "ledgers,
on licensed 7.1" in a scope paragraph this PR wrote, and the remeasurement is
what made the fold wide enough for that to matter. A folded stock-item name now
suggests and does not resolve; byte equality is unaffected. ADR 0016 §1 no
longer says the rules are identical for both classes, because only the
identifier rules are.

Two `REMOTEID` comments now cite TALLY_PROTOCOL_REFERENCE.md §9.3 rather than
the implementation guide: the behaviour is a gateway observation and the
reference is where those live.

Four mutation controls, one per fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): close four holes, and label what §9.4d measured

The previous round's fixes each opened a smaller hole behind them.

- Identifier canonicalisation dropped `-` and `/` but nothing else, so two
  codes differing only in unmeasured punctuation collapsed into one. It now
  removes only the two separators the gateway was measured to fold.
- A family larger than the cap was skipped without recording its size, so a
  withheld family reported no count at all. It now reports how many share the
  identifier even when the holders are not expanded.
- A hint pointing away from an exact name was only honoured below the cap.
  An exact name no longer outranks a hint at any family size.
- A stock item could resolve on a fold. It may now suggest on one and
  nothing more, and the test asserts the reason, not only the outcome.

Three of the round's earlier tests asserted the outcome where the fix was
about the work done to reach it, and passed with the fix reverted. Two
test-only counters (`HOLDER_EXPANSIONS`, `CANDIDATE_SEARCHES`) now let them
assert the work.

§9.4d recorded `AND` for `&` as rejected, but the probe that produced the row
sent a name with `AND CO` appended, against a master carrying no `&`. That
measures an added suffix. The substitution was re-measured against
`Profit & Loss A/c`, a reserved ledger present in every company, and the
section now carries four accurately-labelled rows and says which of them it
measured. The verdict was unchanged; the label was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): say what §9.4d's probe counted, and cite the guide by name

The method paragraph still described the first run alone — twelve variants,
eight vouchers deleted — after the re-measurement added six more. It now
counts both runs.

`the_master_fold_stops_where_tally_stops` cited "§3.3b" without naming the
document that carries it, which is `IMPLEMENTATION_GUIDE.md`, not this
reference; the same three rows now also point at §9.4d, where two of them
were re-measured on licensed 7.1.

Compatibility surface resealed for the changed pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(source-draft): one selection predicate, and real evidence on the capture fixture

Three findings from the same review, all about a claim being wider than what
produced it.

**The summary took any saved ledger for a current-session choice.** The
`<select>` and its status line both use `catalogSelections[key] === entry.ledger`
— a value re-read against the capture in front of the operator — while the
summary used `Boolean(entry.ledger)`, which is true for a target saved in an
earlier session and never re-read. So beside a saved value the control showed
nothing selected, the status line called it unverified, and the summary said
the operator had chosen, dropping the candidate count and the guidance they
needed to judge it. The predicate is computed once now and all three read it.

**The capture fixture carried zeroed evidence.** A file named for a capture
claimed a zero response digest, a zero request digest, zero bytes and a zero
source digest. The retained capture has a provenance sidecar carrying the
genuine request and response digests and the response size, and the source
document has a real digest of its own; all four are now in the fixture and
asserted by the producer test against that sidecar, so they cannot go back to
placeholders while the file still calls itself a capture. `capture_id` has no
captured counterpart — it is minted per read — and stays a synthetic UUID,
now shaped like the others.

**The NFD comment restated external behaviour without citing it.** It now
cites §9.4d, which sent an NFD spelling at an NFC master on licensed 7.1 and
watched Tally reject it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(source-draft): drive the grouped control at a live catalogue's size

The capture is nine ledgers and the fabricated catalogues are three. Both are
real shapes; neither is a real size, and size is the one dimension no capture
can supply, because no lab company carries thousands of ledgers.

Two thousand and one targets, one of them matched: the narrowed group still
leads with the single match, and the full list is still 2,001 long. Slicing
the full list to 200 fails this and nothing else in the file — every other
test here works at three targets, where truncation is invisible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): four more, three of them behind last round's own fixes

**A byte is not a unit of evidence.** The code threshold was `canonical.len()`,
which is UTF-8 bytes. An admitted dash variant is three of them, so `AB–123`
measured eight against a constant named `MIN_CODE_IDENTIFIER_CHARS` and became
decisive on five alphanumerics — while its ASCII twin `AB-123` reduces to
`AB123` and is refused. The same code identified or did not depending on which
dash the document carried. It now counts letters and digits, which also closes
the padding shape bytes could never see. The upper bound stays in bytes: it
bounds what the index stores, and a byte cap admits no more characters than it
says.

**One family's size is not the size of their union.** A skipped family of
thirty beside an identifier reaching a thirty-first reported thirty. The listed
masters not in the skipped family are provably disjoint from it, so they are
added — a bisection per master, no set built. It stays a lower bound, and the
field now says so rather than describing the implementation.

**The memo's repeat count read half its key.** Entries are keyed by source key
*and* the masters the identifiers reached, but repetition was counted on the
key alone, so 1,024 hint variants of one name filled a cache nothing asks for
twice and the pair that did repeat behind them could not be inserted — the
source-order defect again, through the back door. Counted on `(key,
identifiers)` now, still borrowed.

**Only one side of an untrusted pair was bounded.** The catalog constructor has
an aggregate byte budget because count and length bounds do not bound their
product; the source side had none, and 40,000 individually valid entities are
two and a half gigabytes of names before this module clones one. 16 MiB,
checked at `bind`, counting both retained folds beside the name.

Each fix has a test whose control is the fix reverted, and each control fails
its own test alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(source-draft): carry the candidate state, do not rebuild it downstream

There are two ways a candidate listing comes back empty beside a nonzero
count, and they call for opposite things from the operator: a family the
binder declined to slice, where a fuller source name helps, and a report that
ran out of room, where nothing the operator writes will. The screen told them
apart by the refusal reason, which named only one of the two withheld shapes —
so a family withheld under `IdentifierConflict` reached the budget sentence and
said the report had run out of room when it had not.

Adding the second reason to that check would be the same defect with a longer
condition. The state travels instead:

- `Candidates::listing()` returns the state as the word `#[serde(tag =
  "listing")]` already puts on the wire, and a test serializes every variant
  and asserts the accessor against the tag, so the DTO reader and the JSON
  reader cannot come to disagree.
- The DTO's `candidates_truncated` is replaced by `candidate_listing`, not
  joined by it: the boolean was `is_incomplete()`, true for both withheld and
  truncated, which was the conflation itself.
- The screen branches on the carried word. The refusal reason still chooses
  the lead sentence, which is what it is for.

The agent surface already derived its own flag from `listing` and was correct
throughout; only the desktop projection flattened the state and then tried to
rebuild it.

Also reseals the compatibility surface against the final bytes. The previous
reseal ran before `cargo fmt`, which rewrote a pinned file underneath it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(source-draft): cover the second withheld shape, and stop calling it truncated

The mechanical rewrite of `candidates_truncated` into `candidate_listing`
turned the `no_discriminating_candidate` fixture into `"truncated"` — which is
the exact conflation this change removes, reproduced by the change itself.

`a family withheld under a different reason is not reported as a full report`
is the shape the review found: a family withheld under `identifier_conflict`,
which the reason-based inference did not recognise and which therefore reached
the budget sentence. Reverting the branch to the reason check fails it and
nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(source-draft): a choice settles what the operator wants, not a disagreement

The summary was suppressed whenever a `bound_target` merely existed beside a
current-session selection. So choosing B where the capture defended A left
nothing on screen saying the two differed — while the line beside it said the
target had been re-read and bound, which reads as agreement.

The comparison is against the target now, not against whether one exists.
Identical: silence, which is what the suppression is for. Different: the
binding's own ledger is named, and the operator is told their choice stands.

Both halves are tested, because a suppression and a message are each other's
control: returning `null` unconditionally fails the disagreement test, and a
message on agreement would fail the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(master-binding): the separator cuts both ways, and the memo excluded its own reason

**A separator reveals a date and hides two.** The date guard ran on the
canonical only, where removing `-` turns `2025-09-11` into a recognisable
`20250911`. It also turns `DATED20250911-20250912` into one sixteen-digit run
that reads as no date at all, and `is_period` does not see it either, because
its eight-digit case admits a year followed by a year and `0911` is neither. A
date range identified, and a period label is the single thing two unrelated
masters most reliably share. The guard now runs on both spellings; neither
subsumes the other. What is *not* fixed is written above `carries_plausible_date`:
a partly separated range still escapes all three guards, and the obvious
generalisation was considered and rejected, because refusing every sixteen-digit
account number containing a date-shaped window throws away the identifiers this
module exists to use.

**The memo refused to hold the searches it existed for.** `worth_holding`
excluded results larger than the candidate cap, reasoning that a large result
costs what recomputing it costs — which assumes it is recomputed once. A name
reaching a large family through shared tokens, repeated across a draft, is the
case where it is recomputed per row, and it was exactly the case excluded.
`collect_candidates` now sorts into the caller's order and truncates, so every
result is holdable and the per-row allocation of a whole family is gone;
`found` is still the full union, so the count is unchanged. The ordering is one
function used by both the truncation and the sort, because a disagreement
between them would drop a candidate silently. The now-vacuous size test is
removed rather than left as a trap.

**The candidates came from a different index than the reason.** `NameAmbiguous`
is decided on the resolving fold and the list was built from the wide one,
which is not always coarser: `master_identity_key` replaces `-` but not `/`, so
`AB/CD` and `AB CD` are one master to `verified_fold` and two to it. The
operator was shown an ambiguity with a complete-looking list of one. The
resolving-key holders are offered too — correct however the two folds relate,
where widening the wide fold would fix one example and leave the list assembled
from the wrong index.

**A request refusable from its own arguments spent two live reads first.** The
tool schema admits names the core refuses. Parsing now happens before
`verified_company` and before the catalogue read, and `master_report` takes
parsed entities so the ordering is not a convention to remember.

Each fix has a test whose control is the fix reverted. The last one's control
is the endpoint: nothing is listening on it, so a read attempted before parsing
comes back as a failure to connect rather than `master_name_unsafe`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: label conservative candidate counts

* docs: specify candidate count precision for consumers

* fix: memoize repeated derived candidate keys

* perf: bisect large identifier holder membership

* chore: reseal catalogue membership correction

* docs(agent): describe candidate count precision

* fix(ui): keep withheld identifier conflicts visible

* fix(master-binding): separate count precision from listing completeness, and key the memo on both folds

**The memo key named one fold while the search read two.** `collect_candidates`
reads `entity.binding_key` as well as `entity.key` — I added that on #288 so the
masters which *caused* a name ambiguity are the ones listed — and I did not
extend the key that guards it. `AB-CD` and `AB–CD` share a wide key, because
`comparison_key` maps every dash variant to `-`, and differ under the resolving
fold, because §9.4d sent an en dash at a live master and Tally rejected it. The
gateway's own refusal is what makes the keys diverge, and the memo crossed them:
one spelling could be served the other's candidates.

The key now carries both folds and the fingerprint hashes both. Only the key
half is observable — a coarser fingerprint cannot serve a wrong result, it can
only re-admit entries the pre-count exists to exclude, which needs a thousand
colliding keys against nine dash variants — so the fingerprint is right by
construction and its doc says so rather than a test implying otherwise.

**Count precision and listing completeness are different questions.**
`count_is_lower_bound()` derived the first from the second, so a p…
lamemustafa added a commit that referenced this pull request Sep 15, 2026
…314)

* docs(tally): four findings left open when #306 was merged too early

These arrived on #306 after it was merged. Two are corrections to text I
wrote, and one is the same failure in two places: a qualification stated in
prose while the thing beside it does the unqualified thing anyway.

**The case clause was symmetric while the note admitted it was not.**
`ascii_lower(candidate) == ascii_lower(tally_name)` also accepts an
UPPERCASE candidate against a lowercase master, a direction never sent — so
the symmetric form asserts a second experiment, exactly as a canonical form
does for the separator. The note below it said so and the predicate kept the
clause. Now `candidate == ascii_lower(tally_name)`, which is what the
capture sent: a lowercase candidate against a master carrying uppercase.
All four clauses are directional and each is one measured result.

**`PROMPT_PLAYBOOK.md` still mandated NFC normalization for name keys**, and
it is the active Phase 2 implementation authority — so the reference could
carry the MEASURED row saying normalising is wrong while the document people
build from told them to normalise. Withdrawn with a dated deviation citing
the measurement: an NFD spelling of a UI-created NFC ledger was rejected
with `EXCEPTIONS=1` while the NFC spelling created it, and an NFD create
read back with identical codepoints. Case folding and the non-Latin fixtures
stay; normalisation goes.

**`IMPLEMENTATION_GUIDE.md` §3.4 kept the fingerprint alive as duplicate
prevention.** My sentence — "it is still true everywhere §3.3a does not
reach" — preserved the withdrawn mandate in every case §3.3a excludes, while
§3.4a of the same patch says the tuple cannot distinguish a retry from a
legitimate identical payment and so prevents no duplicate anywhere. Two
paragraphs of one patch contradicting each other is how a withdrawn mandate
comes back. What is true outside §3.3a's reach is that there is no proven
mechanism there at all, which is narrower and less comfortable.

**`IMPROVEMENT_PLAN_2026H2.md` cited both sections wrongly.** The signal
table is `IMPLEMENTATION_GUIDE.md` §3.4a, not §6.2 (Unicode round-trip), and
the reference's `REMOTEID` rule is §9.3 — `§3.3a` is a section of the guide,
not of the reference, so the citation pointed at a section that does not
exist. A supersession note that lands readers away from the decision it
claims to identify makes the old mandate harder to disambiguate, not easier.

Reference resealed: 1 changed file, pins unchanged at 211.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): directional case fold, NFC withdrawal reaches every site

Five review findings, docs-only.

Defect A (2 findings, P1): PROMPT_PLAYBOOK.md Phase 2 item 5 prescribed
a symmetric case-insensitive collation for name keys. §9.4b's
accepts(candidate, tally_name) predicate only measured a lowercase
candidate against an uppercase master; the reverse was never sent. A
symmetric fold accepts that unverified direction and can bind a
voucher to the wrong master. Restated as the directional predicate.

Defect B (2 findings, P1): the NFC/NFD withdrawal did not reach three
other sites that all instructed matching or reading masters back by
'normalized name' without excluding NFC, or flagged NFC normalization
only when applied asymmetrically:
- PROMPT_PLAYBOOK.md Phase 2 item 5 (paired with the case fix above)
- PROMPT_PLAYBOOK.md Phase 4 step 4 (readback verification)
- PROMPT_PLAYBOOK.md Phase 2 adversarial review item 4
- IMPROVEMENT_PLAN_2026H2.md §8.6
Each now carries a dated deviation note pointing at
TALLY_PROTOCOL_REFERENCE.md §9.4b, in the style already used for the
existing 2026-09-11 fingerprint deviation.

Over-correction (1 finding, P2): IMPLEMENTATION_GUIDE.md §3.4a claimed
no proven duplicate-prevention mechanism exists outside §3.3a's
REMOTEID path. TALLY_PROTOCOL_REFERENCE.md §9.8 is VERIFIED that Manual
numbering with PREVENTDUPLICATES=Yes cleanly rejects a duplicate
(CREATED=0, ALTERED=0, EXCEPTIONS=1) where Automatic numbering silently
creates one — a second proven mechanism, already noted in §3.3 of the
same file. Narrowed the claim to what's actually unproven (an automatic
dedupe decision from the fingerprint tuple) while keeping the correct
part: the fingerprint must never auto-suppress.

No code changes. TALLY_PROTOCOL_REFERENCE.md (pinned) is untouched;
compatibility surface pin count unchanged at 211. Section-number gate
scripts pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): scope the name fold to its baseline, keep the one proven dedupe

Three findings left open on #314, each checked against the tree before
being accepted.

**The name fold is prescribed outside the SKU it was measured on.**
Phase 4's readback step told implementers to match masters with §9.4b's
`accepts()` predicate. §9.4b's case-folding and hyphen-for-space rows
carry no licensed-SKU qualification, so they inherit §0's Edit Log 7.0
Educational baseline; the only row measured on a licensed 7.1 instance is
NFC/NFD, and it points the other way — exact codepoints. Phase 4 runs
against licensed TallyPrime. Applying an Educational-baseline fold there
can bind a write to an account Tally keeps distinct. Phase 4 now matches
on exact codepoints and widens only where a licensed capture or the
connected instance's compatibility result qualifies the predicate.

**The fingerprint correction stopped at the plan.** IMPROVEMENT_PLAN was
repointed to §3.4a but Phase 4 item 6 still sent implementers to
IMPLEMENTATION_GUIDE §6.2 — the Unicode round-trip result, which says
nothing about fingerprints — and therefore away from the signal table and
its automatic-suppression constraints.

**Withdrawing the tuple withdrew a mechanism it had no claim over.**
§3.4's "there is no proven duplicate-prevention mechanism there at all"
is absolute, and false: §9.8 is VERIFIED that Manual numbering with
`PREVENTDUPLICATES=Yes` preserves the supplied number and rejects a
duplicate cleanly (`CREATED=0, ALTERED=0, EXCEPTIONS=1`). The claim is
now scoped to what lies outside both §3.3a's REMOTEID path and §9.8's,
with a sentence saying explicitly that §9.8 supplies a different
mechanism rather than rehabilitating the fingerprint — the tuple stays
withdrawn everywhere.

Surface resealed (rehash → seal → repoint) after the rebase onto #289;
gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): one name-matching rule per step, and let it be the gated one

The scope gate added in `40dee282` was inserted *between* two directives
that still prescribed the fold unconditionally, so Phase 4 step 4 stated
its name-matching rule three times: unqualified before the gate, gated,
and unqualified again in the closing deviation. An implementer reading to
the end of the step finds the fold reinstated. Codex flagged the third; the
first was the same defect and is fixed with it.

Step 4 now names the gate as the only matching rule in the step, and the
NFC deviation says explicitly that it *removes* normalization without
widening what remains — on an unqualified licensed SKU, exact codepoints
and nothing else.

Phase 2 item 5 carried the same unqualified prescription and was not
flagged. Fixed anyway: Phase 2 reads a licensed instance too, and a read is
the worse place to be wrong, because the mirror rows built there are what
later binding decisions resolve against — a fold that merges two masters on
read merges them everywhere downstream.

The lesson is the doc-level form of "a fix that adds a branch must assert
on the new branch": a qualifier inserted between two unqualified statements
qualifies neither. Add the gate, then audit every sibling directive in the
same step for whether it inherits it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): narrow the 9.8 exception to the case 9.8 actually measured

The previous commit withdrew an over-broad claim and replaced it with another
one. It said Manual numbering with `PREVENTDUPLICATES=Yes` is "a proven
duplicate-prevention mechanism". §9.8 measured one thing: how a **failed
`Alter`** behaves under that setting. Its own rule says *"Do not apply the
failed-`Alter` observation to a different request identity mechanism"*, and a
crash retry — the case §3.4 was discussing — sends a `Create`.

It overreaches on SKU as well. §9.8 carries no licensed qualification for the
numbering path; the scope clarification beneath it covers a licensed *Journal*
`ACTION="Create"` repeat carrying `REMOTEID` and says in terms that it does not
establish voucher-number identity, the configured numbering method, or other
request shapes.

Both sites now state the result as what it is — for a failed `Alter` on §9.8's
baseline, Manual numbering converts a silent duplicate into a clean rejection —
with request shape, SKU and voucher type named as the three limits. The tuple
stays withdrawn everywhere; §9.8 reports how one failure mode behaves under one
setting, which is a different kind of thing from a mechanism.

A paragraph is added saying why this needed two corrections: **a claim is not
made safe by being a correction.** Withdrawing one over-broad statement is the
moment you are most likely to write another, because the writing feels like
narrowing while the sentence is widening something else.

`IMPROVEMENT_PLAN_2026H2.md` §8.6 carried the same defect twice over: it
prescribed the directional fold with no scope gate, and its NFC deviation then
reinstated the fold unconditionally — the identical shape fixed in the playbook
last commit, in a file the playbook's own fix did not reach. §8.6 now defers to
the playbook's SCOPE GATE and its deviation says explicitly that removing
normalization does not decide whether any fold is permitted on top.

Audited rather than assumed: all three `accepts()` prescription sites across the
tally docs now carry a gate or an exact-codepoint instruction within eight lines.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): withdraw a licence claim I inferred rather than measured

**The NFC/NFD capture is not licensed evidence.** The scope gate added two
commits ago said "only the NFC/NFD row was measured on a licensed 7.1
instance". It was not. `TALLY_PROTOCOL_REFERENCE.md` records that capture as
"2026-08-19, TallyPrime 7.1, port 9001" and says nothing about licence mode;
`tests/fixtures/encoding/PROVENANCE.md` records that day's instance as **EDU**.
I supplied "licensed" from a stored note about what port 9001 usually runs,
not from the document, and then used it as the premise of an argument about
which evidence is qualified.

Correcting it makes the gate **stricter**, not weaker: with that row withdrawn,
**no** row of §9.4b is qualified on a licensed SKU, so there is no licensed
evidence to widen towards at all. The gate's instruction is unchanged — exact
codepoints — and now rests on something the tree actually says.

**Two identity authorities still said "normalised name".** The withdrawal was
carried to four sites and I reported it complete; `IMPLEMENTATION_GUIDE.md`
§3.5 and `TALLY_PROTOCOL_REFERENCE.md`'s `LASTMID` entry were not among them,
and both are exactly where an implementer looks for the readback rule — so an
implementer following the identity guidance rather than the deviation could
still resolve an NFD create onto a distinct pre-existing NFC master. Both now
say normalised never means NFC/NFD-normalised and defer to the scope gate.

Audited rather than counted this time: no `by normalised name` or `by
normalized name` directive remains anywhere in the tally docs.

The lesson is one already written down and not applied here: a recalled fact
is not a measured one. A note saying what an instance usually is cannot
qualify a specific capture — only that capture's provenance can.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): exact-only is the right rule for binding and the wrong one for creating

The scope gate said "match on exact codepoints" and stopped there, as though
strictness were automatically safe. It is not, and the failure is in the
opposite direction from the one the gate was written to prevent.

If the connected SKU **does** share the Educational case behaviour, an existing
`FOO` reads as ABSENT for a requested `foo` under exact matching. A step that
creates whatever it finds missing then writes a SECOND master differing only by
case — a duplicate in a client's book, produced by the conservative rule. The
loose rule misbinds; the strict rule duplicates. Neither is safe, and the gate
offered no third option.

It has three outcomes now: **bind** on an exact match; **create** only when no
master differs from the requested name by case or separator alone; otherwise
**refuse and raise it for a human**. A near-collision on an unqualified SKU is
exactly where neither automatic answer is defensible.

The near-collision set is computed with §9.4b's fold used as a **detector**,
never as a binder — and that distinction is what makes it legitimate on an
unqualified SKU. Folding to conclude "something similar exists, stop" needs no
licensed qualification, because the conclusion is a refusal rather than a
write. Unverified evidence can justify caution; it cannot justify action.

Separately, `TALLY_PROTOCOL_REFERENCE.md`'s crash-retry paragraph still called
Manual + `PREVENTDUPLICATES=Yes` a qualified duplicate-rejection mechanism for a
repeated number, which is the generalisation corrected in the guide two commits
ago — the correction had not reached the behavioural authority, which is the
document an implementer trusts most. It now names the failed-`Alter` scope, the
UNVERIFIED `Create` retry and the UNVERIFIED licensed SKU, and says plainly not
to read it as promising a crash retry is safe.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a detector must be wider than a binder, not the same rule reversed

The near-collision guard added last commit reused §9.4b's `accepts()` as its
detector, and `accepts()` is **directional**. For a requested `FOO` against an
existing `foo`, `accepts(FOO, foo)` is false — so the guard misses precisely
the collision it exists to catch, and Phase 4 goes on to create the duplicate
master. The hazard survived inside its own fix.

The detector now folds symmetrically and deliberately over-wide:
case-insensitive both ways, hyphen and space interchangeable both ways,
leading and trailing whitespace ignored, internal whitespace runs collapsed.

Several of those rows are UNVERIFIED as *matching* behaviour, and that is the
point rather than an oversight. **An unverified equivalence cannot justify a
write, but it is ample reason to stop and ask.** The asymmetry in consequences
sets the direction: a detector that misses a collision creates a duplicate in a
client's book, while a detector that over-fires costs one question to a human.
Fail toward the question.

That generalises past this guard — wherever the same fold serves two purposes,
the safe width differs by purpose, and reusing one predicate for both silently
adopts the wrong width for one of them.

Also: the licence correction cited `tests/fixtures/encoding/PROVENANCE.md`,
which exists at no such path. The evidence is at
`src-tauri/crates/bridge-tally-protocol/tests/fixtures/encoding/PROVENANCE.md`,
verified present. I took the short path from the review comment that raised the
finding instead of resolving it — in a commit whose whole subject was citing
provenance accurately.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): the guard ran after the write it was meant to prevent

Five findings, three P1, and the first one voids the guard entirely.

**The master-creation guard sat inside step 4, which begins "after counters
accept".** It ran after dispatch. By then `ACTION="Create"` against a name
Tally considers matching has already returned `CREATED=0, ALTERED=1` and
overwritten the existing master with the retry payload (guide §3.6, plan §8.5).
A refusal raised during readback protects nothing — the client's master is
already gone. It is now step **3a**, on the intent, before any import is sent.

**And the harm was described wrongly, in the safer direction.** The guard said a
near-collision "makes a SECOND master differing only by case". The measured
behaviour is an overwrite in place: the existing master's group, opening balance
and GST registration are replaced by whatever the new payload carried. A
duplicate is visible in a ledger list. An overwrite is not.

**The detector omitted canonical equivalence.** §9.4b's exact-codepoint result
came from an EDU instance, so a licensed SKU that folds NFC/NFD is not excluded
— and an NFD request beside an existing NFC master would pass exact lookup *and*
the detector, and overwrite it. Canonical equivalents now collide. This does not
reintroduce NFC normalisation into matching: the binder still compares exact
codepoints. One folds to refuse, the other folds to write, and only the second
needs evidence.

**A read receipt cannot qualify a write predicate.** The gate offered "where the
compatibility result for the connected instance says it holds" as a widening
route. `compatibility/README` defines that evidence as a live-**read** receipt
and says it "never establishes ... any write behavior". Withdrawn here, in Phase
2 item 5, and in the plan's §8.6, all of which carried it.

**What does qualify, now that #288 has landed: §9.4d.** It re-ran §9.4b's method
on licensed 7.1 silver with `education_mode=false`, importing vouchers naming
folded spellings and reading the **day book** back to see which master each
posted against. That is observed write behaviour on the SKU this project writes
to — for **ledgers**. So ledgers on licensed 7.1 match under §9.4d's rows, and
every other master type still matches on exact codepoints, because §9.4d
measured nothing about them.

**§9.5 was carrying implementation policy under a `VERIFIED` marker.** The
measurement is `LASTMID=0`; "which name rule applies" is a choice. A reader
collecting VERIFIED claims would have collected one that was never measured. The
prescription is now a separate block that says so — the evidence marker governs
the paragraph it opens, and a prescription placed under it inherits a confidence
it has not earned.

Surface resealed after the rebase onto #288; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep unknown-outcome recovery read-only across active guidance

* docs(tally): keep unknown outcomes out of dispatchable state

* docs(tally): require intent fields before binding existing masters

* docs(tally): align scoped qualification and held recovery across plans

* docs: keep slash matching directional

* docs: limit Silver slash comparison direction

* docs(tally): four findings left open when #306 was merged too early

These arrived on #306 after it was merged. Two are corrections to text I
wrote, and one is the same failure in two places: a qualification stated in
prose while the thing beside it does the unqualified thing anyway.

**The case clause was symmetric while the note admitted it was not.**
`ascii_lower(candidate) == ascii_lower(tally_name)` also accepts an
UPPERCASE candidate against a lowercase master, a direction never sent — so
the symmetric form asserts a second experiment, exactly as a canonical form
does for the separator. The note below it said so and the predicate kept the
clause. Now `candidate == ascii_lower(tally_name)`, which is what the
capture sent: a lowercase candidate against a master carrying uppercase.
All four clauses are directional and each is one measured result.

**`PROMPT_PLAYBOOK.md` still mandated NFC normalization for name keys**, and
it is the active Phase 2 implementation authority — so the reference could
carry the MEASURED row saying normalising is wrong while the document people
build from told them to normalise. Withdrawn with a dated deviation citing
the measurement: an NFD spelling of a UI-created NFC ledger was rejected
with `EXCEPTIONS=1` while the NFC spelling created it, and an NFD create
read back with identical codepoints. Case folding and the non-Latin fixtures
stay; normalisation goes.

**`IMPLEMENTATION_GUIDE.md` §3.4 kept the fingerprint alive as duplicate
prevention.** My sentence — "it is still true everywhere §3.3a does not
reach" — preserved the withdrawn mandate in every case §3.3a excludes, while
§3.4a of the same patch says the tuple cannot distinguish a retry from a
legitimate identical payment and so prevents no duplicate anywhere. Two
paragraphs of one patch contradicting each other is how a withdrawn mandate
comes back. What is true outside §3.3a's reach is that there is no proven
mechanism there at all, which is narrower and less comfortable.

**`IMPROVEMENT_PLAN_2026H2.md` cited both sections wrongly.** The signal
table is `IMPLEMENTATION_GUIDE.md` §3.4a, not §6.2 (Unicode round-trip), and
the reference's `REMOTEID` rule is §9.3 — `§3.3a` is a section of the guide,
not of the reference, so the citation pointed at a section that does not
exist. A supersession note that lands readers away from the decision it
claims to identify makes the old mandate harder to disambiguate, not easier.

Reference resealed: 1 changed file, pins unchanged at 211.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): directional case fold, NFC withdrawal reaches every site

Five review findings, docs-only.

Defect A (2 findings, P1): PROMPT_PLAYBOOK.md Phase 2 item 5 prescribed
a symmetric case-insensitive collation for name keys. §9.4b's
accepts(candidate, tally_name) predicate only measured a lowercase
candidate against an uppercase master; the reverse was never sent. A
symmetric fold accepts that unverified direction and can bind a
voucher to the wrong master. Restated as the directional predicate.

Defect B (2 findings, P1): the NFC/NFD withdrawal did not reach three
other sites that all instructed matching or reading masters back by
'normalized name' without excluding NFC, or flagged NFC normalization
only when applied asymmetrically:
- PROMPT_PLAYBOOK.md Phase 2 item 5 (paired with the case fix above)
- PROMPT_PLAYBOOK.md Phase 4 step 4 (readback verification)
- PROMPT_PLAYBOOK.md Phase 2 adversarial review item 4
- IMPROVEMENT_PLAN_2026H2.md §8.6
Each now carries a dated deviation note pointing at
TALLY_PROTOCOL_REFERENCE.md §9.4b, in the style already used for the
existing 2026-09-11 fingerprint deviation.

Over-correction (1 finding, P2): IMPLEMENTATION_GUIDE.md §3.4a claimed
no proven duplicate-prevention mechanism exists outside §3.3a's
REMOTEID path. TALLY_PROTOCOL_REFERENCE.md §9.8 is VERIFIED that Manual
numbering with PREVENTDUPLICATES=Yes cleanly rejects a duplicate
(CREATED=0, ALTERED=0, EXCEPTIONS=1) where Automatic numbering silently
creates one — a second proven mechanism, already noted in §3.3 of the
same file. Narrowed the claim to what's actually unproven (an automatic
dedupe decision from the fingerprint tuple) while keeping the correct
part: the fingerprint must never auto-suppress.

No code changes. TALLY_PROTOCOL_REFERENCE.md (pinned) is untouched;
compatibility surface pin count unchanged at 211. Section-number gate
scripts pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): scope the name fold to its baseline, keep the one proven dedupe

Three findings left open on #314, each checked against the tree before
being accepted.

**The name fold is prescribed outside the SKU it was measured on.**
Phase 4's readback step told implementers to match masters with §9.4b's
`accepts()` predicate. §9.4b's case-folding and hyphen-for-space rows
carry no licensed-SKU qualification, so they inherit §0's Edit Log 7.0
Educational baseline; the only row measured on a licensed 7.1 instance is
NFC/NFD, and it points the other way — exact codepoints. Phase 4 runs
against licensed TallyPrime. Applying an Educational-baseline fold there
can bind a write to an account Tally keeps distinct. Phase 4 now matches
on exact codepoints and widens only where a licensed capture or the
connected instance's compatibility result qualifies the predicate.

**The fingerprint correction stopped at the plan.** IMPROVEMENT_PLAN was
repointed to §3.4a but Phase 4 item 6 still sent implementers to
IMPLEMENTATION_GUIDE §6.2 — the Unicode round-trip result, which says
nothing about fingerprints — and therefore away from the signal table and
its automatic-suppression constraints.

**Withdrawing the tuple withdrew a mechanism it had no claim over.**
§3.4's "there is no proven duplicate-prevention mechanism there at all"
is absolute, and false: §9.8 is VERIFIED that Manual numbering with
`PREVENTDUPLICATES=Yes` preserves the supplied number and rejects a
duplicate cleanly (`CREATED=0, ALTERED=0, EXCEPTIONS=1`). The claim is
now scoped to what lies outside both §3.3a's REMOTEID path and §9.8's,
with a sentence saying explicitly that §9.8 supplies a different
mechanism rather than rehabilitating the fingerprint — the tuple stays
withdrawn everywhere.

Surface resealed (rehash → seal → repoint) after the rebase onto #289;
gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): one name-matching rule per step, and let it be the gated one

The scope gate added in `40dee282` was inserted *between* two directives
that still prescribed the fold unconditionally, so Phase 4 step 4 stated
its name-matching rule three times: unqualified before the gate, gated,
and unqualified again in the closing deviation. An implementer reading to
the end of the step finds the fold reinstated. Codex flagged the third; the
first was the same defect and is fixed with it.

Step 4 now names the gate as the only matching rule in the step, and the
NFC deviation says explicitly that it *removes* normalization without
widening what remains — on an unqualified licensed SKU, exact codepoints
and nothing else.

Phase 2 item 5 carried the same unqualified prescription and was not
flagged. Fixed anyway: Phase 2 reads a licensed instance too, and a read is
the worse place to be wrong, because the mirror rows built there are what
later binding decisions resolve against — a fold that merges two masters on
read merges them everywhere downstream.

The lesson is the doc-level form of "a fix that adds a branch must assert
on the new branch": a qualifier inserted between two unqualified statements
qualifies neither. Add the gate, then audit every sibling directive in the
same step for whether it inherits it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): narrow the 9.8 exception to the case 9.8 actually measured

The previous commit withdrew an over-broad claim and replaced it with another
one. It said Manual numbering with `PREVENTDUPLICATES=Yes` is "a proven
duplicate-prevention mechanism". §9.8 measured one thing: how a **failed
`Alter`** behaves under that setting. Its own rule says *"Do not apply the
failed-`Alter` observation to a different request identity mechanism"*, and a
crash retry — the case §3.4 was discussing — sends a `Create`.

It overreaches on SKU as well. §9.8 carries no licensed qualification for the
numbering path; the scope clarification beneath it covers a licensed *Journal*
`ACTION="Create"` repeat carrying `REMOTEID` and says in terms that it does not
establish voucher-number identity, the configured numbering method, or other
request shapes.

Both sites now state the result as what it is — for a failed `Alter` on §9.8's
baseline, Manual numbering converts a silent duplicate into a clean rejection —
with request shape, SKU and voucher type named as the three limits. The tuple
stays withdrawn everywhere; §9.8 reports how one failure mode behaves under one
setting, which is a different kind of thing from a mechanism.

A paragraph is added saying why this needed two corrections: **a claim is not
made safe by being a correction.** Withdrawing one over-broad statement is the
moment you are most likely to write another, because the writing feels like
narrowing while the sentence is widening something else.

`IMPROVEMENT_PLAN_2026H2.md` §8.6 carried the same defect twice over: it
prescribed the directional fold with no scope gate, and its NFC deviation then
reinstated the fold unconditionally — the identical shape fixed in the playbook
last commit, in a file the playbook's own fix did not reach. §8.6 now defers to
the playbook's SCOPE GATE and its deviation says explicitly that removing
normalization does not decide whether any fold is permitted on top.

Audited rather than assumed: all three `accepts()` prescription sites across the
tally docs now carry a gate or an exact-codepoint instruction within eight lines.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): withdraw a licence claim I inferred rather than measured

**The NFC/NFD capture is not licensed evidence.** The scope gate added two
commits ago said "only the NFC/NFD row was measured on a licensed 7.1
instance". It was not. `TALLY_PROTOCOL_REFERENCE.md` records that capture as
"2026-08-19, TallyPrime 7.1, port 9001" and says nothing about licence mode;
`tests/fixtures/encoding/PROVENANCE.md` records that day's instance as **EDU**.
I supplied "licensed" from a stored note about what port 9001 usually runs,
not from the document, and then used it as the premise of an argument about
which evidence is qualified.

Correcting it makes the gate **stricter**, not weaker: with that row withdrawn,
**no** row of §9.4b is qualified on a licensed SKU, so there is no licensed
evidence to widen towards at all. The gate's instruction is unchanged — exact
codepoints — and now rests on something the tree actually says.

**Two identity authorities still said "normalised name".** The withdrawal was
carried to four sites and I reported it complete; `IMPLEMENTATION_GUIDE.md`
§3.5 and `TALLY_PROTOCOL_REFERENCE.md`'s `LASTMID` entry were not among them,
and both are exactly where an implementer looks for the readback rule — so an
implementer following the identity guidance rather than the deviation could
still resolve an NFD create onto a distinct pre-existing NFC master. Both now
say normalised never means NFC/NFD-normalised and defer to the scope gate.

Audited rather than counted this time: no `by normalised name` or `by
normalized name` directive remains anywhere in the tally docs.

The lesson is one already written down and not applied here: a recalled fact
is not a measured one. A note saying what an instance usually is cannot
qualify a specific capture — only that capture's provenance can.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): exact-only is the right rule for binding and the wrong one for creating

The scope gate said "match on exact codepoints" and stopped there, as though
strictness were automatically safe. It is not, and the failure is in the
opposite direction from the one the gate was written to prevent.

If the connected SKU **does** share the Educational case behaviour, an existing
`FOO` reads as ABSENT for a requested `foo` under exact matching. A step that
creates whatever it finds missing then writes a SECOND master differing only by
case — a duplicate in a client's book, produced by the conservative rule. The
loose rule misbinds; the strict rule duplicates. Neither is safe, and the gate
offered no third option.

It has three outcomes now: **bind** on an exact match; **create** only when no
master differs from the requested name by case or separator alone; otherwise
**refuse and raise it for a human**. A near-collision on an unqualified SKU is
exactly where neither automatic answer is defensible.

The near-collision set is computed with §9.4b's fold used as a **detector**,
never as a binder — and that distinction is what makes it legitimate on an
unqualified SKU. Folding to conclude "something similar exists, stop" needs no
licensed qualification, because the conclusion is a refusal rather than a
write. Unverified evidence can justify caution; it cannot justify action.

Separately, `TALLY_PROTOCOL_REFERENCE.md`'s crash-retry paragraph still called
Manual + `PREVENTDUPLICATES=Yes` a qualified duplicate-rejection mechanism for a
repeated number, which is the generalisation corrected in the guide two commits
ago — the correction had not reached the behavioural authority, which is the
document an implementer trusts most. It now names the failed-`Alter` scope, the
UNVERIFIED `Create` retry and the UNVERIFIED licensed SKU, and says plainly not
to read it as promising a crash retry is safe.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a detector must be wider than a binder, not the same rule reversed

The near-collision guard added last commit reused §9.4b's `accepts()` as its
detector, and `accepts()` is **directional**. For a requested `FOO` against an
existing `foo`, `accepts(FOO, foo)` is false — so the guard misses precisely
the collision it exists to catch, and Phase 4 goes on to create the duplicate
master. The hazard survived inside its own fix.

The detector now folds symmetrically and deliberately over-wide:
case-insensitive both ways, hyphen and space interchangeable both ways,
leading and trailing whitespace ignored, internal whitespace runs collapsed.

Several of those rows are UNVERIFIED as *matching* behaviour, and that is the
point rather than an oversight. **An unverified equivalence cannot justify a
write, but it is ample reason to stop and ask.** The asymmetry in consequences
sets the direction: a detector that misses a collision creates a duplicate in a
client's book, while a detector that over-fires costs one question to a human.
Fail toward the question.

That generalises past this guard — wherever the same fold serves two purposes,
the safe width differs by purpose, and reusing one predicate for both silently
adopts the wrong width for one of them.

Also: the licence correction cited `tests/fixtures/encoding/PROVENANCE.md`,
which exists at no such path. The evidence is at
`src-tauri/crates/bridge-tally-protocol/tests/fixtures/encoding/PROVENANCE.md`,
verified present. I took the short path from the review comment that raised the
finding instead of resolving it — in a commit whose whole subject was citing
provenance accurately.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): the guard ran after the write it was meant to prevent

Five findings, three P1, and the first one voids the guard entirely.

**The master-creation guard sat inside step 4, which begins "after counters
accept".** It ran after dispatch. By then `ACTION="Create"` against a name
Tally considers matching has already returned `CREATED=0, ALTERED=1` and
overwritten the existing master with the retry payload (guide §3.6, plan §8.5).
A refusal raised during readback protects nothing — the client's master is
already gone. It is now step **3a**, on the intent, before any import is sent.

**And the harm was described wrongly, in the safer direction.** The guard said a
near-collision "makes a SECOND master differing only by case". The measured
behaviour is an overwrite in place: the existing master's group, opening balance
and GST registration are replaced by whatever the new payload carried. A
duplicate is visible in a ledger list. An overwrite is not.

**The detector omitted canonical equivalence.** §9.4b's exact-codepoint result
came from an EDU instance, so a licensed SKU that folds NFC/NFD is not excluded
— and an NFD request beside an existing NFC master would pass exact lookup *and*
the detector, and overwrite it. Canonical equivalents now collide. This does not
reintroduce NFC normalisation into matching: the binder still compares exact
codepoints. One folds to refuse, the other folds to write, and only the second
needs evidence.

**A read receipt cannot qualify a write predicate.** The gate offered "where the
compatibility result for the connected instance says it holds" as a widening
route. `compatibility/README` defines that evidence as a live-**read** receipt
and says it "never establishes ... any write behavior". Withdrawn here, in Phase
2 item 5, and in the plan's §8.6, all of which carried it.

**What does qualify, now that #288 has landed: §9.4d.** It re-ran §9.4b's method
on licensed 7.1 silver with `education_mode=false`, importing vouchers naming
folded spellings and reading the **day book** back to see which master each
posted against. That is observed write behaviour on the SKU this project writes
to — for **ledgers**. So ledgers on licensed 7.1 match under §9.4d's rows, and
every other master type still matches on exact codepoints, because §9.4d
measured nothing about them.

**§9.5 was carrying implementation policy under a `VERIFIED` marker.** The
measurement is `LASTMID=0`; "which name rule applies" is a choice. A reader
collecting VERIFIED claims would have collected one that was never measured. The
prescription is now a separate block that says so — the evidence marker governs
the paragraph it opens, and a prescription placed under it inherits a confidence
it has not earned.

Surface resealed after the rebase onto #288; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a measured row is directional, and its reverse is not measured

Two findings, opposite in direction, and together they are the binder/detector
distinction working as written — I had applied §9.4d's slash row to neither.

**The detector omitted slash.** §9.4d records a **slash supplied against a
space-bearing master** as MATCHED on licensed 7.1, so a requested `A/B` reaches
an existing `A B`. A detector folding only hyphen and space lets that through
to a `Create` that overwrites the master. Slash is in the detector now, both
ways, and the paragraph says why it is easy to omit: §9.4b never sent one, so
the row exists only in §9.4d and a reader working from the older table will not
find it.

**The binder must NOT fold slash symmetrically.** §9.4d's rows are directional
as written — the left column is what the integration supplies, the right is
what the master carries. `a slash where the master has a space` is MATCHED; the
reverse, supplying a space against a master carrying `/`, has **no row** and is
UNVERIFIED. Hyphen is the exception that proves the shape: §9.4d carries *both*
directions for it as two separate rows, which is why both may be bound.

So the rule now stated in the gate: **the reverse of a measured row is not a
measured row.** Reading that table as a set of equivalences rather than a set of
directed observations is how an unmeasured direction gets bound — and it is the
same mistake §9.4b's own text warns about for the hyphen case, one table later.

The two findings sit either side of a line this PR already drew: the detector
folds wide because it concludes in a refusal, the binder folds only what was
measured because it concludes in a write. Getting slash wrong in both directions
at once is what made that concrete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(tally): reseal the surface after rebasing onto #310

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a fold belongs at lookup, never in the key — and the race is detected, not prevented

Three findings, all P1, and two of them are the same mistake at two phases.

**Folding a name to build a KEY destroys a row.** A catalogue holding both
`Alpha-Beta` and `Alpha Beta` holds two masters Tally keeps apart. Routing the
key through a fold collapses them into one mirror row before any ambiguity
handling can run — the second row is not ambiguous, it is *gone*, and nothing
downstream can recover a distinction destroyed on the way in. The same happens
to a case-differing pair under the directional predicate.

Both Phase 2 and Phase 4 now say it: **store rows under their exact codepoints;
fold only when resolving a request against those rows.** A fold reaching more
than one row is then an ambiguity to surface, which is only possible because
both rows still exist. This is the read-side twin of the binder/detector rule
already in this document — where a fold sits decides what it can destroy.

**The pre-dispatch guard cannot close its own window, and saying so is the
fix.** Another Tally client or an operator can create a colliding master between
the pre-read and the dispatch; §3.6's overwrite then happens anyway. Bridge's
single-writer actor owns Bridge's writes, not Tally's, and no amount of reading
earlier removes that.

What closes it is that the gateway reports the collision where it cannot hide:
a `Create` that finds a match returns **`CREATED=0, ALTERED=1`**. A create
dispatched from step 3a must therefore assert `CREATED=1`, and `ALTERED=1` is
not a success with a different label — it means a master that existed at
dispatch time has been overwritten with this payload. Alarm, halt, restore from
the pre-image; never a promotion to CONFIRMED.

Stated as detection rather than prevention on purpose. The guard narrows the
window to the dispatch itself; the counter assertion is what makes losing that
race loud instead of silent. A guard that claimed to eliminate the race would be
the more dangerous document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(tally): reseal merged 314 surface

* docs(tally): four findings left open when #306 was merged too early

These arrived on #306 after it was merged. Two are corrections to text I
wrote, and one is the same failure in two places: a qualification stated in
prose while the thing beside it does the unqualified thing anyway.

**The case clause was symmetric while the note admitted it was not.**
`ascii_lower(candidate) == ascii_lower(tally_name)` also accepts an
UPPERCASE candidate against a lowercase master, a direction never sent — so
the symmetric form asserts a second experiment, exactly as a canonical form
does for the separator. The note below it said so and the predicate kept the
clause. Now `candidate == ascii_lower(tally_name)`, which is what the
capture sent: a lowercase candidate against a master carrying uppercase.
All four clauses are directional and each is one measured result.

**`PROMPT_PLAYBOOK.md` still mandated NFC normalization for name keys**, and
it is the active Phase 2 implementation authority — so the reference could
carry the MEASURED row saying normalising is wrong while the document people
build from told them to normalise. Withdrawn with a dated deviation citing
the measurement: an NFD spelling of a UI-created NFC ledger was rejected
with `EXCEPTIONS=1` while the NFC spelling created it, and an NFD create
read back with identical codepoints. Case folding and the non-Latin fixtures
stay; normalisation goes.

**`IMPLEMENTATION_GUIDE.md` §3.4 kept the fingerprint alive as duplicate
prevention.** My sentence — "it is still true everywhere §3.3a does not
reach" — preserved the withdrawn mandate in every case §3.3a excludes, while
§3.4a of the same patch says the tuple cannot distinguish a retry from a
legitimate identical payment and so prevents no duplicate anywhere. Two
paragraphs of one patch contradicting each other is how a withdrawn mandate
comes back. What is true outside §3.3a's reach is that there is no proven
mechanism there at all, which is narrower and less comfortable.

**`IMPROVEMENT_PLAN_2026H2.md` cited both sections wrongly.** The signal
table is `IMPLEMENTATION_GUIDE.md` §3.4a, not §6.2 (Unicode round-trip), and
the reference's `REMOTEID` rule is §9.3 — `§3.3a` is a section of the guide,
not of the reference, so the citation pointed at a section that does not
exist. A supersession note that lands readers away from the decision it
claims to identify makes the old mandate harder to disambiguate, not easier.

Reference resealed: 1 changed file, pins unchanged at 211.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): directional case fold, NFC withdrawal reaches every site

Five review findings, docs-only.

Defect A (2 findings, P1): PROMPT_PLAYBOOK.md Phase 2 item 5 prescribed
a symmetric case-insensitive collation for name keys. §9.4b's
accepts(candidate, tally_name) predicate only measured a lowercase
candidate against an uppercase master; the reverse was never sent. A
symmetric fold accepts that unverified direction and can bind a
voucher to the wrong master. Restated as the directional predicate.

Defect B (2 findings, P1): the NFC/NFD withdrawal did not reach three
other sites that all instructed matching or reading masters back by
'normalized name' without excluding NFC, or flagged NFC normalization
only when applied asymmetrically:
- PROMPT_PLAYBOOK.md Phase 2 item 5 (paired with the case fix above)
- PROMPT_PLAYBOOK.md Phase 4 step 4 (readback verification)
- PROMPT_PLAYBOOK.md Phase 2 adversarial review item 4
- IMPROVEMENT_PLAN_2026H2.md §8.6
Each now carries a dated deviation note pointing at
TALLY_PROTOCOL_REFERENCE.md §9.4b, in the style already used for the
existing 2026-09-11 fingerprint deviation.

Over-correction (1 finding, P2): IMPLEMENTATION_GUIDE.md §3.4a claimed
no proven duplicate-prevention mechanism exists outside §3.3a's
REMOTEID path. TALLY_PROTOCOL_REFERENCE.md §9.8 is VERIFIED that Manual
numbering with PREVENTDUPLICATES=Yes cleanly rejects a duplicate
(CREATED=0, ALTERED=0, EXCEPTIONS=1) where Automatic numbering silently
creates one — a second proven mechanism, already noted in §3.3 of the
same file. Narrowed the claim to what's actually unproven (an automatic
dedupe decision from the fingerprint tuple) while keeping the correct
part: the fingerprint must never auto-suppress.

No code changes. TALLY_PROTOCOL_REFERENCE.md (pinned) is untouched;
compatibility surface pin count unchanged at 211. Section-number gate
scripts pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): scope the name fold to its baseline, keep the one proven dedupe

Three findings left open on #314, each checked against the tree before
being accepted.

**The name fold is prescribed outside the SKU it was measured on.**
Phase 4's readback step told implementers to match masters with §9.4b's
`accepts()` predicate. §9.4b's case-folding and hyphen-for-space rows
carry no licensed-SKU qualification, so they inherit §0's Edit Log 7.0
Educational baseline; the only row measured on a licensed 7.1 instance is
NFC/NFD, and it points the other way — exact codepoints. Phase 4 runs
against licensed TallyPrime. Applying an Educational-baseline fold there
can bind a write to an account Tally keeps distinct. Phase 4 now matches
on exact codepoints and widens only where a licensed capture or the
connected instance's compatibility result qualifies the predicate.

**The fingerprint correction stopped at the plan.** IMPROVEMENT_PLAN was
repointed to §3.4a but Phase 4 item 6 still sent implementers to
IMPLEMENTATION_GUIDE §6.2 — the Unicode round-trip result, which says
nothing about fingerprints — and therefore away from the signal table and
its automatic-suppression constraints.

**Withdrawing the tuple withdrew a mechanism it had no claim over.**
§3.4's "there is no proven duplicate-prevention mechanism there at all"
is absolute, and false: §9.8 is VERIFIED that Manual numbering with
`PREVENTDUPLICATES=Yes` preserves the supplied number and rejects a
duplicate cleanly (`CREATED=0, ALTERED=0, EXCEPTIONS=1`). The claim is
now scoped to what lies outside both §3.3a's REMOTEID path and §9.8's,
with a sentence saying explicitly that §9.8 supplies a different
mechanism rather than rehabilitating the fingerprint — the tuple stays
withdrawn everywhere.

Surface resealed (rehash → seal → repoint) after the rebase onto #289;
gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): one name-matching rule per step, and let it be the gated one

The scope gate added in `40dee282` was inserted *between* two directives
that still prescribed the fold unconditionally, so Phase 4 step 4 stated
its name-matching rule three times: unqualified before the gate, gated,
and unqualified again in the closing deviation. An implementer reading to
the end of the step finds the fold reinstated. Codex flagged the third; the
first was the same defect and is fixed with it.

Step 4 now names the gate as the only matching rule in the step, and the
NFC deviation says explicitly that it *removes* normalization without
widening what remains — on an unqualified licensed SKU, exact codepoints
and nothing else.

Phase 2 item 5 carried the same unqualified prescription and was not
flagged. Fixed anyway: Phase 2 reads a licensed instance too, and a read is
the worse place to be wrong, because the mirror rows built there are what
later binding decisions resolve against — a fold that merges two masters on
read merges them everywhere downstream.

The lesson is the doc-level form of "a fix that adds a branch must assert
on the new branch": a qualifier inserted between two unqualified statements
qualifies neither. Add the gate, then audit every sibling directive in the
same step for whether it inherits it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): narrow the 9.8 exception to the case 9.8 actually measured

The previous commit withdrew an over-broad claim and replaced it with another
one. It said Manual numbering with `PREVENTDUPLICATES=Yes` is "a proven
duplicate-prevention mechanism". §9.8 measured one thing: how a **failed
`Alter`** behaves under that setting. Its own rule says *"Do not apply the
failed-`Alter` observation to a different request identity mechanism"*, and a
crash retry — the case §3.4 was discussing — sends a `Create`.

It overreaches on SKU as well. §9.8 carries no licensed qualification for the
numbering path; the scope clarification beneath it covers a licensed *Journal*
`ACTION="Create"` repeat carrying `REMOTEID` and says in terms that it does not
establish voucher-number identity, the configured numbering method, or other
request shapes.

Both sites now state the result as what it is — for a failed `Alter` on §9.8's
baseline, Manual numbering converts a silent duplicate into a clean rejection —
with request shape, SKU and voucher type named as the three limits. The tuple
stays withdrawn everywhere; §9.8 reports how one failure mode behaves under one
setting, which is a different kind of thing from a mechanism.

A paragraph is added saying why this needed two corrections: **a claim is not
made safe by being a correction.** Withdrawing one over-broad statement is the
moment you are most likely to write another, because the writing feels like
narrowing while the sentence is widening something else.

`IMPROVEMENT_PLAN_2026H2.md` §8.6 carried the same defect twice over: it
prescribed the directional fold with no scope gate, and its NFC deviation then
reinstated the fold unconditionally — the identical shape fixed in the playbook
last commit, in a file the playbook's own fix did not reach. §8.6 now defers to
the playbook's SCOPE GATE and its deviation says explicitly that removing
normalization does not decide whether any fold is permitted on top.

Audited rather than assumed: all three `accepts()` prescription sites across the
tally docs now carry a gate or an exact-codepoint instruction within eight lines.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): withdraw a licence claim I inferred rather than measured

**The NFC/NFD capture is not licensed evidence.** The scope gate added two
commits ago said "only the NFC/NFD row was measured on a licensed 7.1
instance". It was not. `TALLY_PROTOCOL_REFERENCE.md` records that capture as
"2026-08-19, TallyPrime 7.1, port 9001" and says nothing about licence mode;
`tests/fixtures/encoding/PROVENANCE.md` records that day's instance as **EDU**.
I supplied "licensed" from a stored note about what port 9001 usually runs,
not from the document, and then used it as the premise of an argument about
which evidence is qualified.

Correcting it makes the gate **stricter**, not weaker: with that row withdrawn,
**no** row of §9.4b is qualified on a licensed SKU, so there is no licensed
evidence to widen towards at all. The gate's instruction is unchanged — exact
codepoints — and now rests on something the tree actually says.

**Two identity authorities still said "normalised name".** The withdrawal was
carried to four sites and I reported it complete; `IMPLEMENTATION_GUIDE.md`
§3.5 and `TALLY_PROTOCOL_REFERENCE.md`'s `LASTMID` entry were not among them,
and both are exactly where an implementer looks for the readback rule — so an
implementer following the identity guidance rather than the deviation could
still resolve an NFD create onto a distinct pre-existing NFC master. Both now
say normalised never means NFC/NFD-normalised and defer to the scope gate.

Audited rather than counted this time: no `by normalised name` or `by
normalized name` directive remains anywhere in the tally docs.

The lesson is one already written down and not applied here: a recalled fact
is not a measured one. A note saying what an instance usually is cannot
qualify a specific capture — only that capture's provenance can.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): exact-only is the right rule for binding and the wrong one for creating

The scope gate said "match on exact codepoints" and stopped there, as though
strictness were automatically safe. It is not, and the failure is in the
opposite direction from the one the gate was written to prevent.

If the connected SKU **does** share the Educational case behaviour, an existing
`FOO` reads as ABSENT for a requested `foo` under exact matching. A step that
creates whatever it finds missing then writes a SECOND master differing only by
case — a duplicate in a client's book, produced by the conservative rule. The
loose rule misbinds; the strict rule duplicates. Neither is safe, and the gate
offered no third option.

It has three outcomes now: **bind** on an exact match; **create** only when no
master differs from the requested name by case or separator alone; otherwise
**refuse and raise it for a human**. A near-collision on an unqualified SKU is
exactly where neither automatic answer is defensible.

The near-collision set is computed with §9.4b's fold used as a **detector**,
never as a binder — and that distinction is what makes it legitimate on an
unqualified SKU. Folding to conclude "something similar exists, stop" needs no
licensed qualification, because the conclusion is a refusal rather than a
write. Unverified evidence can justify caution; it cannot justify action.

Separately, `TALLY_PROTOCOL_REFERENCE.md`'s crash-retry paragraph still called
Manual + `PREVENTDUPLICATES=Yes` a qualified duplicate-rejection mechanism for a
repeated number, which is the generalisation corrected in the guide two commits
ago — the correction had not reached the behavioural authority, which is the
document an implementer trusts most. It now names the failed-`Alter` scope, the
UNVERIFIED `Create` retry and the UNVERIFIED licensed SKU, and says plainly not
to read it as promising a crash retry is safe.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a detector must be wider than a binder, not the same rule reversed

The near-collision guard added last commit reused §9.4b's `accepts()` as its
detector, and `accepts()` is **directional**. For a requested `FOO` against an
existing `foo`, `accepts(FOO, foo)` is false — so the guard misses precisely
the collision it exists to catch, and Phase 4 goes on to create the duplicate
master. The hazard survived inside its own fix.

The detector now folds symmetrically and deliberately over-wide:
case-insensitive both ways, hyphen and space interchangeable both ways,
leading and trailing whitespace ignored, internal whitespace runs collapsed.

Several of those rows are UNVERIFIED as *matching* behaviour, and that is the
point rather than an oversight. **An unverified equivalence cannot justify a
write, but it is ample reason to stop and ask.** The asymmetry in consequences
sets the direction: a detector that misses a collision creates a duplicate in a
client's book, while a detector that over-fires costs one question to a human.
Fail toward the question.

That generalises past this guard — wherever the same fold serves two purposes,
the safe width differs by purpose, and reusing one predicate for both silently
adopts the wrong width for one of them.

Also: the licence correction cited `tests/fixtures/encoding/PROVENANCE.md`,
which exists at no such path. The evidence is at
`src-tauri/crates/bridge-tally-protocol/tests/fixtures/encoding/PROVENANCE.md`,
verified present. I took the short path from the review comment that raised the
finding instead of resolving it — in a commit whose whole subject was citing
provenance accurately.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): the guard ran after the write it was meant to prevent

Five findings, three P1, and the first one voids the guard entirely.

**The master-creation guard sat inside step 4, which begins "after counters
accept".** It ran after dispatch. By then `ACTION="Create"` against a name
Tally considers matching has already returned `CREATED=0, ALTERED=1` and
overwritten the existing master with the retry payload (guide §3.6, plan §8.5).
A refusal raised during readback protects nothing — the client's master is
already gone. It is now step **3a**, on the intent, before any import is sent.

**And the harm was described wrongly, in the safer direction.** The guard said a
near-collision "makes a SECOND master differing only by case". The measured
behaviour is an overwrite in place: the existing master's group, opening balance
and GST registration are replaced by whatever the new payload carried. A
duplicate is visible in a ledger list. An overwrite is not.

**The detector omitted canonical equivalence.** §9.4b's exact-codepoint result
came from an EDU instance, so a licensed SKU that folds NFC/NFD is not excluded
— and an NFD request beside an existing NFC master would pass exact lookup *and*
the detector, and overwrite it. Canonical equivalents now collide. This does not
reintroduce NFC normalisation into matching: the binder still compares exact
codepoints. One folds to refuse, the other folds to write, and only the second
needs evidence.

**A read receipt cannot qualify a write predicate.** The gate offered "where the
compatibility result for the connected instance says it holds" as a widening
route. `compatibility/README` defines that evidence as a live-**read** receipt
and says it "never establishes ... any write behavior". Withdrawn here, in Phase
2 item 5, and in the plan's §8.6, all of which carried it.

**What does qualify, now that #288 has landed: §9.4d.** It re-ran §9.4b's method
on licensed 7.1 silver with `education_mode=false`, importing vouchers naming
folded spellings and reading the **day book** back to see which master each
posted against. That is observed write behaviour on the SKU this project writes
to — for **ledgers**. So ledgers on licensed 7.1 match under §9.4d's rows, and
every other master type still matches on exact codepoints, because §9.4d
measured nothing about them.

**§9.5 was carrying implementation policy under a `VERIFIED` marker.** The
measurement is `LASTMID=0`; "which name rule applies" is a choice. A reader
collecting VERIFIED claims would have collected one that was never measured. The
prescription is now a separate block that says so — the evidence marker governs
the paragraph it opens, and a prescription placed under it inherits a confidence
it has not earned.

Surface resealed after the rebase onto #288; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a measured row is directional, and its reverse is not measured

Two findings, opposite in direction, and together they are the binder/detector
distinction working as written — I had applied §9.4d's slash row to neither.

**The detector omitted slash.** §9.4d records a **slash supplied against a
space-bearing master** as MATCHED on licensed 7.1, so a requested `A/B` reaches
an existing `A B`. A detector folding only hyphen and space lets that through
to a `Create` that overwrites the master. Slash is in the detector now, both
ways, and the paragraph says why it is easy to omit: §9.4b never sent one, so
the row exists only in §9.4d and a reader working from the older table will not
find it.

**The binder must NOT fold slash symmetrically.** §9.4d's rows are directional
as written — the left column is what the integration supplies, the right is
what the master carries. `a slash where the master has a space` is MATCHED; the
reverse, supplying a space against a master carrying `/`, has **no row** and is
UNVERIFIED. Hyphen is the exception that proves the shape: §9.4d carries *both*
directions for it as two separate rows, which is why both may be bound.

So the rule now stated in the gate: **the reverse of a measured row is not a
measured row.** Reading that table as a set of equivalences rather than a set of
directed observations is how an unmeasured direction gets bound — and it is the
same mistake §9.4b's own text warns about for the hyphen case, one table later.

The two findings sit either side of a line this PR already drew: the detector
folds wide because it concludes in a refusal, the binder folds only what was
measured because it concludes in a write. Getting slash wrong in both directions
at once is what made that concrete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a fold belongs at lookup, never in the key — and the race is detected, not prevented

Three findings, all P1, and two of them are the same mistake at two phases.

**Folding a name to build a KEY destroys a row.** A catalogue holding both
`Alpha-Beta` and `Alpha Beta` holds two masters Tally keeps apart. Routing the
key through a fold collapses them into one mirror row before any ambiguity
handling can run — the second row is not ambiguous, it is *gone*, and nothing
downstream can recover a distinction destroyed on the way in. The same happens
to a case-differing pair under the directional predicate.

Both Phase 2 and Phase 4 now say it: **store rows under their exact codepoints;
fold only when resolving a request against those rows.** A fold reaching more
than one row is then an ambiguity to surface, which is only possible because
both rows still exist. This is the read-side twin of the binder/detector rule
already in this document — where a fold sits decides what it can destroy.

**The pre-dispatch guard cannot close its own window, and saying so is the
fix.** Another Tally client or an operator can create a colliding master between
the pre-read and the dispatch; §3.6's overwrite then happens anyway. Bridge's
single-writer actor owns Bridge's writes, not Tally's, and no amount of reading
earlier removes that.

What closes it is that the gateway reports the collision where it cannot hide:
a `Create` that finds a match returns **`CREATED=0, ALTERED=1`**. A create
dispatched from step 3a must therefore assert `CREATED=1`, and `ALTERED=1` is
not a success with a different label — it means a master that existed at
dispatch time has been overwritten with this payload. Alarm, halt, restore from
the pre-image; never a promotion to CONFIRMED.

Stated as detection rather than prevention on purpose. The guard narrows the
window to the dispatch itself; the counter assertion is what makes losing that
race loud instead of silent. A guard that claimed to eliminate the race would be
the more dangerous document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(tally): reseal after rebasing onto master

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): do not prescribe a recovery that does not exist

**The create race had a remedy Bridge cannot perform.** Step 3a said an
unexpected `ALTERED=1` meant "alarm, halt, and restore from the pre-image". The
outbox carries a pre-image for **alters** — step 1 says so, `(alters)` — because
an alter knows what it is replacing. A create does not: the pre-read found
nothing, so there is no pre-image, and the master a foreign writer created in
the window is one Bridge never observed. Its group, opening balance and
registration are gone and are not in our records.

Prescribing that recovery would have sent an operator looking for a pre-image
that was never taken, during an incident. What the step now gives them is the
truth and the material to act on it: the master name, the exact payload that
overwrote it, the dispatch time, and a statement that the prior conte…
lamemustafa added a commit that referenced this pull request Sep 15, 2026
* docs(tally): four findings left open when #306 was merged too early

These arrived on #306 after it was merged. Two are corrections to text I
wrote, and one is the same failure in two places: a qualification stated in
prose while the thing beside it does the unqualified thing anyway.

**The case clause was symmetric while the note admitted it was not.**
`ascii_lower(candidate) == ascii_lower(tally_name)` also accepts an
UPPERCASE candidate against a lowercase master, a direction never sent — so
the symmetric form asserts a second experiment, exactly as a canonical form
does for the separator. The note below it said so and the predicate kept the
clause. Now `candidate == ascii_lower(tally_name)`, which is what the
capture sent: a lowercase candidate against a master carrying uppercase.
All four clauses are directional and each is one measured result.

**`PROMPT_PLAYBOOK.md` still mandated NFC normalization for name keys**, and
it is the active Phase 2 implementation authority — so the reference could
carry the MEASURED row saying normalising is wrong while the document people
build from told them to normalise. Withdrawn with a dated deviation citing
the measurement: an NFD spelling of a UI-created NFC ledger was rejected
with `EXCEPTIONS=1` while the NFC spelling created it, and an NFD create
read back with identical codepoints. Case folding and the non-Latin fixtures
stay; normalisation goes.

**`IMPLEMENTATION_GUIDE.md` §3.4 kept the fingerprint alive as duplicate
prevention.** My sentence — "it is still true everywhere §3.3a does not
reach" — preserved the withdrawn mandate in every case §3.3a excludes, while
§3.4a of the same patch says the tuple cannot distinguish a retry from a
legitimate identical payment and so prevents no duplicate anywhere. Two
paragraphs of one patch contradicting each other is how a withdrawn mandate
comes back. What is true outside §3.3a's reach is that there is no proven
mechanism there at all, which is narrower and less comfortable.

**`IMPROVEMENT_PLAN_2026H2.md` cited both sections wrongly.** The signal
table is `IMPLEMENTATION_GUIDE.md` §3.4a, not §6.2 (Unicode round-trip), and
the reference's `REMOTEID` rule is §9.3 — `§3.3a` is a section of the guide,
not of the reference, so the citation pointed at a section that does not
exist. A supersession note that lands readers away from the decision it
claims to identify makes the old mandate harder to disambiguate, not easier.

Reference resealed: 1 changed file, pins unchanged at 211.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): directional case fold, NFC withdrawal reaches every site

Five review findings, docs-only.

Defect A (2 findings, P1): PROMPT_PLAYBOOK.md Phase 2 item 5 prescribed
a symmetric case-insensitive collation for name keys. §9.4b's
accepts(candidate, tally_name) predicate only measured a lowercase
candidate against an uppercase master; the reverse was never sent. A
symmetric fold accepts that unverified direction and can bind a
voucher to the wrong master. Restated as the directional predicate.

Defect B (2 findings, P1): the NFC/NFD withdrawal did not reach three
other sites that all instructed matching or reading masters back by
'normalized name' without excluding NFC, or flagged NFC normalization
only when applied asymmetrically:
- PROMPT_PLAYBOOK.md Phase 2 item 5 (paired with the case fix above)
- PROMPT_PLAYBOOK.md Phase 4 step 4 (readback verification)
- PROMPT_PLAYBOOK.md Phase 2 adversarial review item 4
- IMPROVEMENT_PLAN_2026H2.md §8.6
Each now carries a dated deviation note pointing at
TALLY_PROTOCOL_REFERENCE.md §9.4b, in the style already used for the
existing 2026-09-11 fingerprint deviation.

Over-correction (1 finding, P2): IMPLEMENTATION_GUIDE.md §3.4a claimed
no proven duplicate-prevention mechanism exists outside §3.3a's
REMOTEID path. TALLY_PROTOCOL_REFERENCE.md §9.8 is VERIFIED that Manual
numbering with PREVENTDUPLICATES=Yes cleanly rejects a duplicate
(CREATED=0, ALTERED=0, EXCEPTIONS=1) where Automatic numbering silently
creates one — a second proven mechanism, already noted in §3.3 of the
same file. Narrowed the claim to what's actually unproven (an automatic
dedupe decision from the fingerprint tuple) while keeping the correct
part: the fingerprint must never auto-suppress.

No code changes. TALLY_PROTOCOL_REFERENCE.md (pinned) is untouched;
compatibility surface pin count unchanged at 211. Section-number gate
scripts pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): scope the name fold to its baseline, keep the one proven dedupe

Three findings left open on #314, each checked against the tree before
being accepted.

**The name fold is prescribed outside the SKU it was measured on.**
Phase 4's readback step told implementers to match masters with §9.4b's
`accepts()` predicate. §9.4b's case-folding and hyphen-for-space rows
carry no licensed-SKU qualification, so they inherit §0's Edit Log 7.0
Educational baseline; the only row measured on a licensed 7.1 instance is
NFC/NFD, and it points the other way — exact codepoints. Phase 4 runs
against licensed TallyPrime. Applying an Educational-baseline fold there
can bind a write to an account Tally keeps distinct. Phase 4 now matches
on exact codepoints and widens only where a licensed capture or the
connected instance's compatibility result qualifies the predicate.

**The fingerprint correction stopped at the plan.** IMPROVEMENT_PLAN was
repointed to §3.4a but Phase 4 item 6 still sent implementers to
IMPLEMENTATION_GUIDE §6.2 — the Unicode round-trip result, which says
nothing about fingerprints — and therefore away from the signal table and
its automatic-suppression constraints.

**Withdrawing the tuple withdrew a mechanism it had no claim over.**
§3.4's "there is no proven duplicate-prevention mechanism there at all"
is absolute, and false: §9.8 is VERIFIED that Manual numbering with
`PREVENTDUPLICATES=Yes` preserves the supplied number and rejects a
duplicate cleanly (`CREATED=0, ALTERED=0, EXCEPTIONS=1`). The claim is
now scoped to what lies outside both §3.3a's REMOTEID path and §9.8's,
with a sentence saying explicitly that §9.8 supplies a different
mechanism rather than rehabilitating the fingerprint — the tuple stays
withdrawn everywhere.

Surface resealed (rehash → seal → repoint) after the rebase onto #289;
gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): one name-matching rule per step, and let it be the gated one

The scope gate added in `40dee282` was inserted *between* two directives
that still prescribed the fold unconditionally, so Phase 4 step 4 stated
its name-matching rule three times: unqualified before the gate, gated,
and unqualified again in the closing deviation. An implementer reading to
the end of the step finds the fold reinstated. Codex flagged the third; the
first was the same defect and is fixed with it.

Step 4 now names the gate as the only matching rule in the step, and the
NFC deviation says explicitly that it *removes* normalization without
widening what remains — on an unqualified licensed SKU, exact codepoints
and nothing else.

Phase 2 item 5 carried the same unqualified prescription and was not
flagged. Fixed anyway: Phase 2 reads a licensed instance too, and a read is
the worse place to be wrong, because the mirror rows built there are what
later binding decisions resolve against — a fold that merges two masters on
read merges them everywhere downstream.

The lesson is the doc-level form of "a fix that adds a branch must assert
on the new branch": a qualifier inserted between two unqualified statements
qualifies neither. Add the gate, then audit every sibling directive in the
same step for whether it inherits it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): narrow the 9.8 exception to the case 9.8 actually measured

The previous commit withdrew an over-broad claim and replaced it with another
one. It said Manual numbering with `PREVENTDUPLICATES=Yes` is "a proven
duplicate-prevention mechanism". §9.8 measured one thing: how a **failed
`Alter`** behaves under that setting. Its own rule says *"Do not apply the
failed-`Alter` observation to a different request identity mechanism"*, and a
crash retry — the case §3.4 was discussing — sends a `Create`.

It overreaches on SKU as well. §9.8 carries no licensed qualification for the
numbering path; the scope clarification beneath it covers a licensed *Journal*
`ACTION="Create"` repeat carrying `REMOTEID` and says in terms that it does not
establish voucher-number identity, the configured numbering method, or other
request shapes.

Both sites now state the result as what it is — for a failed `Alter` on §9.8's
baseline, Manual numbering converts a silent duplicate into a clean rejection —
with request shape, SKU and voucher type named as the three limits. The tuple
stays withdrawn everywhere; §9.8 reports how one failure mode behaves under one
setting, which is a different kind of thing from a mechanism.

A paragraph is added saying why this needed two corrections: **a claim is not
made safe by being a correction.** Withdrawing one over-broad statement is the
moment you are most likely to write another, because the writing feels like
narrowing while the sentence is widening something else.

`IMPROVEMENT_PLAN_2026H2.md` §8.6 carried the same defect twice over: it
prescribed the directional fold with no scope gate, and its NFC deviation then
reinstated the fold unconditionally — the identical shape fixed in the playbook
last commit, in a file the playbook's own fix did not reach. §8.6 now defers to
the playbook's SCOPE GATE and its deviation says explicitly that removing
normalization does not decide whether any fold is permitted on top.

Audited rather than assumed: all three `accepts()` prescription sites across the
tally docs now carry a gate or an exact-codepoint instruction within eight lines.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): withdraw a licence claim I inferred rather than measured

**The NFC/NFD capture is not licensed evidence.** The scope gate added two
commits ago said "only the NFC/NFD row was measured on a licensed 7.1
instance". It was not. `TALLY_PROTOCOL_REFERENCE.md` records that capture as
"2026-08-19, TallyPrime 7.1, port 9001" and says nothing about licence mode;
`tests/fixtures/encoding/PROVENANCE.md` records that day's instance as **EDU**.
I supplied "licensed" from a stored note about what port 9001 usually runs,
not from the document, and then used it as the premise of an argument about
which evidence is qualified.

Correcting it makes the gate **stricter**, not weaker: with that row withdrawn,
**no** row of §9.4b is qualified on a licensed SKU, so there is no licensed
evidence to widen towards at all. The gate's instruction is unchanged — exact
codepoints — and now rests on something the tree actually says.

**Two identity authorities still said "normalised name".** The withdrawal was
carried to four sites and I reported it complete; `IMPLEMENTATION_GUIDE.md`
§3.5 and `TALLY_PROTOCOL_REFERENCE.md`'s `LASTMID` entry were not among them,
and both are exactly where an implementer looks for the readback rule — so an
implementer following the identity guidance rather than the deviation could
still resolve an NFD create onto a distinct pre-existing NFC master. Both now
say normalised never means NFC/NFD-normalised and defer to the scope gate.

Audited rather than counted this time: no `by normalised name` or `by
normalized name` directive remains anywhere in the tally docs.

The lesson is one already written down and not applied here: a recalled fact
is not a measured one. A note saying what an instance usually is cannot
qualify a specific capture — only that capture's provenance can.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): exact-only is the right rule for binding and the wrong one for creating

The scope gate said "match on exact codepoints" and stopped there, as though
strictness were automatically safe. It is not, and the failure is in the
opposite direction from the one the gate was written to prevent.

If the connected SKU **does** share the Educational case behaviour, an existing
`FOO` reads as ABSENT for a requested `foo` under exact matching. A step that
creates whatever it finds missing then writes a SECOND master differing only by
case — a duplicate in a client's book, produced by the conservative rule. The
loose rule misbinds; the strict rule duplicates. Neither is safe, and the gate
offered no third option.

It has three outcomes now: **bind** on an exact match; **create** only when no
master differs from the requested name by case or separator alone; otherwise
**refuse and raise it for a human**. A near-collision on an unqualified SKU is
exactly where neither automatic answer is defensible.

The near-collision set is computed with §9.4b's fold used as a **detector**,
never as a binder — and that distinction is what makes it legitimate on an
unqualified SKU. Folding to conclude "something similar exists, stop" needs no
licensed qualification, because the conclusion is a refusal rather than a
write. Unverified evidence can justify caution; it cannot justify action.

Separately, `TALLY_PROTOCOL_REFERENCE.md`'s crash-retry paragraph still called
Manual + `PREVENTDUPLICATES=Yes` a qualified duplicate-rejection mechanism for a
repeated number, which is the generalisation corrected in the guide two commits
ago — the correction had not reached the behavioural authority, which is the
document an implementer trusts most. It now names the failed-`Alter` scope, the
UNVERIFIED `Create` retry and the UNVERIFIED licensed SKU, and says plainly not
to read it as promising a crash retry is safe.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a detector must be wider than a binder, not the same rule reversed

The near-collision guard added last commit reused §9.4b's `accepts()` as its
detector, and `accepts()` is **directional**. For a requested `FOO` against an
existing `foo`, `accepts(FOO, foo)` is false — so the guard misses precisely
the collision it exists to catch, and Phase 4 goes on to create the duplicate
master. The hazard survived inside its own fix.

The detector now folds symmetrically and deliberately over-wide:
case-insensitive both ways, hyphen and space interchangeable both ways,
leading and trailing whitespace ignored, internal whitespace runs collapsed.

Several of those rows are UNVERIFIED as *matching* behaviour, and that is the
point rather than an oversight. **An unverified equivalence cannot justify a
write, but it is ample reason to stop and ask.** The asymmetry in consequences
sets the direction: a detector that misses a collision creates a duplicate in a
client's book, while a detector that over-fires costs one question to a human.
Fail toward the question.

That generalises past this guard — wherever the same fold serves two purposes,
the safe width differs by purpose, and reusing one predicate for both silently
adopts the wrong width for one of them.

Also: the licence correction cited `tests/fixtures/encoding/PROVENANCE.md`,
which exists at no such path. The evidence is at
`src-tauri/crates/bridge-tally-protocol/tests/fixtures/encoding/PROVENANCE.md`,
verified present. I took the short path from the review comment that raised the
finding instead of resolving it — in a commit whose whole subject was citing
provenance accurately.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): the guard ran after the write it was meant to prevent

Five findings, three P1, and the first one voids the guard entirely.

**The master-creation guard sat inside step 4, which begins "after counters
accept".** It ran after dispatch. By then `ACTION="Create"` against a name
Tally considers matching has already returned `CREATED=0, ALTERED=1` and
overwritten the existing master with the retry payload (guide §3.6, plan §8.5).
A refusal raised during readback protects nothing — the client's master is
already gone. It is now step **3a**, on the intent, before any import is sent.

**And the harm was described wrongly, in the safer direction.** The guard said a
near-collision "makes a SECOND master differing only by case". The measured
behaviour is an overwrite in place: the existing master's group, opening balance
and GST registration are replaced by whatever the new payload carried. A
duplicate is visible in a ledger list. An overwrite is not.

**The detector omitted canonical equivalence.** §9.4b's exact-codepoint result
came from an EDU instance, so a licensed SKU that folds NFC/NFD is not excluded
— and an NFD request beside an existing NFC master would pass exact lookup *and*
the detector, and overwrite it. Canonical equivalents now collide. This does not
reintroduce NFC normalisation into matching: the binder still compares exact
codepoints. One folds to refuse, the other folds to write, and only the second
needs evidence.

**A read receipt cannot qualify a write predicate.** The gate offered "where the
compatibility result for the connected instance says it holds" as a widening
route. `compatibility/README` defines that evidence as a live-**read** receipt
and says it "never establishes ... any write behavior". Withdrawn here, in Phase
2 item 5, and in the plan's §8.6, all of which carried it.

**What does qualify, now that #288 has landed: §9.4d.** It re-ran §9.4b's method
on licensed 7.1 silver with `education_mode=false`, importing vouchers naming
folded spellings and reading the **day book** back to see which master each
posted against. That is observed write behaviour on the SKU this project writes
to — for **ledgers**. So ledgers on licensed 7.1 match under §9.4d's rows, and
every other master type still matches on exact codepoints, because §9.4d
measured nothing about them.

**§9.5 was carrying implementation policy under a `VERIFIED` marker.** The
measurement is `LASTMID=0`; "which name rule applies" is a choice. A reader
collecting VERIFIED claims would have collected one that was never measured. The
prescription is now a separate block that says so — the evidence marker governs
the paragraph it opens, and a prescription placed under it inherits a confidence
it has not earned.

Surface resealed after the rebase onto #288; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): a short run of X is data, and decode the capture as UTF-8

Two of the three findings left open when #303 was merged too early. The
third is recorded with its reproduction rather than rushed.

**A bare `X` or `XX` was copied into the fixture verbatim (P1).** The
all-`X` branch treated any token of nothing but `X` as a masking convention
and returned it unchanged — which also bypassed `reserve_source_tokens`, the
one check that exists to stop source text reaching a fixture.
`bank_statement_import` requires `[Xx]{4,}\d*` before it will call something
a masked account, so below four the run is a customer value: an initial, for
instance. Now gated on that same threshold, named `MASK_MIN_XS` beside the
alphabets so the two files cannot drift apart. `XXXXXXXX1234` still keeps
its run and fabricates the digits, which is the case the run exists for.

**The capture was decoded with the host locale (P2).** `pdftotext` emits
UTF-8; `read_text()` without an encoding uses the locale, so on a Windows
Python whose locale is not UTF-8 a raw `Café` becomes mojibake with extra
code points and Indic bytes raise `UnicodeDecodeError` before sanitisation
runs at all. Neither existing check reaches that boundary — CI is
ubuntu-only, and the Unicode cases call `_scrub_plain` with strings that are
already decoded. Pinned to UTF-8 on the read; the write already was.

**Not fixed: the cross-token key collision (P2), recorded instead.**
`_taken` keeps fabricated *tokens* distinct, but the reader concatenates
tokens and strips whitespace, so:

    source 'ACD'  -> 'ZZZ'        key 'ZZZ'
    source 'A CC' -> 'Z' + 'ZZ'   key 'ZZZ'    one mapping row

It is systematic, not rare — the counter is per shape, so the first token of
every shape starts at the alphabet's first letter. Fixing it properly means
the fabricated set must be uniquely decodable after whitespace removal,
which is a change to the fabricator rather than a guard bolted onto it. The
consequence is a fixture that could merge two parties and so fail to catch a
mapping-identity regression for that pair — worth fixing, not worth rushing
into a data-safety tool. The reachable case is in the test file with its
reproduction so it cannot quietly get worse.

Reverting the mask threshold makes the suite red on the first assertion.
Fixture bytes unchanged; neither file is pinned, so no reseal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): classify the masking shape per token, not per character

`MASK_MIN_XS` decided "is this X part of the masking convention?" one
character at a time. A token mixing X with anything else therefore made the
free-position list nonempty, skipped the all-X branch entirely, and carried
its own X straight through: `XAVIER` sanitised to `XZZZZZ`, `ABXXCD` to
`ZZXXZZ`. Those X characters are letters a customer typed, and they reached a
fixture built for a public repository — the precise leak the branch below
them was added to stop, through a narrower door.

The classification is now made once, for the whole token, against the pattern
the parsers actually use: `bank_statement_import` calls something a masked
account only when it matches `[Xx]{4,}\d*`. Inside that shape an X is
structure and survives; everywhere else an X is data and is fabricated like
any other letter.

This also fixes a second defect in the same expression. The old test was
`character != "X"`, uppercase only, so a lowercase mask was not recognised at
all and `xxxx5678` was destroyed to `zzzz1111` — losing the shape the fixture
exists to preserve. The pattern is `[Xx]` because the parser's is.

Three tests asserted the old per-character behaviour, one of them requiring
that the trailing X be preserved. They encoded the defect, so they are
rewritten rather than patched:

- the trailing-X assertion now asserts the opposite, with `XAVIER`,
  `ABXXCD`, `MAX` and `X-RAY` named directly;
- the reservation test floods with tokens that merely contain X and asserts a
  real mask keeps its run while none of the flood carries an X through;
- the exhaustion guard moves from `?XX` to single letters. This matters: with
  X positions now free, `?XX` has 20**3 replacements and can never run out,
  so leaving that shape would have turned a live guard into a test that
  cannot fail.

Mutation-checked: six assertions fail against the pre-fix module, reproducing
`XAVIER -> XZZZZZ` and `ABXXCD -> ZZXXZZ` exactly.

**Known gap, stated rather than glossed.** `sbi-bbox-capture.xml` still holds
one `XX111` token generated under the old rule; the corrected rule would
fabricate it. It is a staleness gap, not a leak — `XX` runs appear twenty
times across the real statements as the *bank's* redaction marks, not
customer letters, and the digits beside them were already fabricated. Every
distinct long digit run in both fixtures was cross-checked against four real
statements spanning three banks: zero matches. Regenerating the fixture needs
the source PDF and is not done here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): honour both mask shapes the parsers recognise

Classifying per token rather than per character was right, but it took the
wrong threshold. `bank_statement_import` recognises **two** account-mask
shapes: `[Xx]{4,}\d*` when deciding whether a standalone narration field is an
account, and `[Xx]+\d+` inside a UPI/IMPS reference, where a run as short as
`XX1234` is the bank's masking rather than a customer's letters. Requiring four
Xs everywhere fabricated `XX1234` into `ZZ1111`, destroying a shape the fixture
exists to preserve.

`_is_mask` is now the union, and still a whole-token test, which is what keeps
`XAVIER`, `ABXXCD` and `MAX` out — an X is structure only when the token is
nothing but a mask, and the short form additionally requires the digits that
make it an account reference rather than an initial. Twelve shapes checked; all
twelve land correctly.

This also settles the staleness gap named in the previous commit: the `XX111`
already in `sbi-bbox-capture.xml` is a mask under the parser's own rule, so it
was never stale.

**The end-to-end check was contradicting the unit cases.** `identifying_tokens`
drops tokens shorter than `IDENTIFYING_LENGTH`, and the survivor set subtracted
every pure-X token as deliberate — so a surviving `X`, `XX` or `XXX`, which the
unit cases define as a leak, was invisible to the artifact check and excused if
seen. The length exclusion exists because a one-digit token has nine possible
replacements and reserving them all starves the allocator; that reasoning is
about digits, not about X. Short tokens containing an X are now included, and
only tokens the parsers would actually call a mask are subtracted.

**The first version of this fix had no test.** Reverting `_is_mask` to the
four-X rule left the whole suite green — the new branch was asserted nowhere,
which is the failure this repository keeps writing down and I reproduced while
fixing someone else's instance of it. Three rows now cover the short form, and
the assertion itself was wrong on first writing: it checked for a fixed-length
run of leading Xs, which is false for `X99`. It now requires every X position to
survive verbatim and every digit position to be fabricated.

Mutation-checked in both directions: green with the union predicate, three
named failures without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep unknown-outcome recovery read-only across active guidance

* docs(tally): keep unknown outcomes out of dispatchable state

* fix(scripts): bind short masks to captured IMPS field context

* docs(tally): require intent fields before binding existing masters

* fix(scripts): withhold mask role when capture context is omitted

* fix(scripts): retain mask context independently of table furniture

* docs(tally): align scoped qualification and held recovery across plans

* docs: keep slash matching directional

* docs: limit Silver slash comparison direction

* docs(tally): four findings left open when #306 was merged too early

These arrived on #306 after it was merged. Two are corrections to text I
wrote, and one is the same failure in two places: a qualification stated in
prose while the thing beside it does the unqualified thing anyway.

**The case clause was symmetric while the note admitted it was not.**
`ascii_lower(candidate) == ascii_lower(tally_name)` also accepts an
UPPERCASE candidate against a lowercase master, a direction never sent — so
the symmetric form asserts a second experiment, exactly as a canonical form
does for the separator. The note below it said so and the predicate kept the
clause. Now `candidate == ascii_lower(tally_name)`, which is what the
capture sent: a lowercase candidate against a master carrying uppercase.
All four clauses are directional and each is one measured result.

**`PROMPT_PLAYBOOK.md` still mandated NFC normalization for name keys**, and
it is the active Phase 2 implementation authority — so the reference could
carry the MEASURED row saying normalising is wrong while the document people
build from told them to normalise. Withdrawn with a dated deviation citing
the measurement: an NFD spelling of a UI-created NFC ledger was rejected
with `EXCEPTIONS=1` while the NFC spelling created it, and an NFD create
read back with identical codepoints. Case folding and the non-Latin fixtures
stay; normalisation goes.

**`IMPLEMENTATION_GUIDE.md` §3.4 kept the fingerprint alive as duplicate
prevention.** My sentence — "it is still true everywhere §3.3a does not
reach" — preserved the withdrawn mandate in every case §3.3a excludes, while
§3.4a of the same patch says the tuple cannot distinguish a retry from a
legitimate identical payment and so prevents no duplicate anywhere. Two
paragraphs of one patch contradicting each other is how a withdrawn mandate
comes back. What is true outside §3.3a's reach is that there is no proven
mechanism there at all, which is narrower and less comfortable.

**`IMPROVEMENT_PLAN_2026H2.md` cited both sections wrongly.** The signal
table is `IMPLEMENTATION_GUIDE.md` §3.4a, not §6.2 (Unicode round-trip), and
the reference's `REMOTEID` rule is §9.3 — `§3.3a` is a section of the guide,
not of the reference, so the citation pointed at a section that does not
exist. A supersession note that lands readers away from the decision it
claims to identify makes the old mandate harder to disambiguate, not easier.

Reference resealed: 1 changed file, pins unchanged at 211.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): directional case fold, NFC withdrawal reaches every site

Five review findings, docs-only.

Defect A (2 findings, P1): PROMPT_PLAYBOOK.md Phase 2 item 5 prescribed
a symmetric case-insensitive collation for name keys. §9.4b's
accepts(candidate, tally_name) predicate only measured a lowercase
candidate against an uppercase master; the reverse was never sent. A
symmetric fold accepts that unverified direction and can bind a
voucher to the wrong master. Restated as the directional predicate.

Defect B (2 findings, P1): the NFC/NFD withdrawal did not reach three
other sites that all instructed matching or reading masters back by
'normalized name' without excluding NFC, or flagged NFC normalization
only when applied asymmetrically:
- PROMPT_PLAYBOOK.md Phase 2 item 5 (paired with the case fix above)
- PROMPT_PLAYBOOK.md Phase 4 step 4 (readback verification)
- PROMPT_PLAYBOOK.md Phase 2 adversarial review item 4
- IMPROVEMENT_PLAN_2026H2.md §8.6
Each now carries a dated deviation note pointing at
TALLY_PROTOCOL_REFERENCE.md §9.4b, in the style already used for the
existing 2026-09-11 fingerprint deviation.

Over-correction (1 finding, P2): IMPLEMENTATION_GUIDE.md §3.4a claimed
no proven duplicate-prevention mechanism exists outside §3.3a's
REMOTEID path. TALLY_PROTOCOL_REFERENCE.md §9.8 is VERIFIED that Manual
numbering with PREVENTDUPLICATES=Yes cleanly rejects a duplicate
(CREATED=0, ALTERED=0, EXCEPTIONS=1) where Automatic numbering silently
creates one — a second proven mechanism, already noted in §3.3 of the
same file. Narrowed the claim to what's actually unproven (an automatic
dedupe decision from the fingerprint tuple) while keeping the correct
part: the fingerprint must never auto-suppress.

No code changes. TALLY_PROTOCOL_REFERENCE.md (pinned) is untouched;
compatibility surface pin count unchanged at 211. Section-number gate
scripts pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): scope the name fold to its baseline, keep the one proven dedupe

Three findings left open on #314, each checked against the tree before
being accepted.

**The name fold is prescribed outside the SKU it was measured on.**
Phase 4's readback step told implementers to match masters with §9.4b's
`accepts()` predicate. §9.4b's case-folding and hyphen-for-space rows
carry no licensed-SKU qualification, so they inherit §0's Edit Log 7.0
Educational baseline; the only row measured on a licensed 7.1 instance is
NFC/NFD, and it points the other way — exact codepoints. Phase 4 runs
against licensed TallyPrime. Applying an Educational-baseline fold there
can bind a write to an account Tally keeps distinct. Phase 4 now matches
on exact codepoints and widens only where a licensed capture or the
connected instance's compatibility result qualifies the predicate.

**The fingerprint correction stopped at the plan.** IMPROVEMENT_PLAN was
repointed to §3.4a but Phase 4 item 6 still sent implementers to
IMPLEMENTATION_GUIDE §6.2 — the Unicode round-trip result, which says
nothing about fingerprints — and therefore away from the signal table and
its automatic-suppression constraints.

**Withdrawing the tuple withdrew a mechanism it had no claim over.**
§3.4's "there is no proven duplicate-prevention mechanism there at all"
is absolute, and false: §9.8 is VERIFIED that Manual numbering with
`PREVENTDUPLICATES=Yes` preserves the supplied number and rejects a
duplicate cleanly (`CREATED=0, ALTERED=0, EXCEPTIONS=1`). The claim is
now scoped to what lies outside both §3.3a's REMOTEID path and §9.8's,
with a sentence saying explicitly that §9.8 supplies a different
mechanism rather than rehabilitating the fingerprint — the tuple stays
withdrawn everywhere.

Surface resealed (rehash → seal → repoint) after the rebase onto #289;
gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): one name-matching rule per step, and let it be the gated one

The scope gate added in `40dee282` was inserted *between* two directives
that still prescribed the fold unconditionally, so Phase 4 step 4 stated
its name-matching rule three times: unqualified before the gate, gated,
and unqualified again in the closing deviation. An implementer reading to
the end of the step finds the fold reinstated. Codex flagged the third; the
first was the same defect and is fixed with it.

Step 4 now names the gate as the only matching rule in the step, and the
NFC deviation says explicitly that it *removes* normalization without
widening what remains — on an unqualified licensed SKU, exact codepoints
and nothing else.

Phase 2 item 5 carried the same unqualified prescription and was not
flagged. Fixed anyway: Phase 2 reads a licensed instance too, and a read is
the worse place to be wrong, because the mirror rows built there are what
later binding decisions resolve against — a fold that merges two masters on
read merges them everywhere downstream.

The lesson is the doc-level form of "a fix that adds a branch must assert
on the new branch": a qualifier inserted between two unqualified statements
qualifies neither. Add the gate, then audit every sibling directive in the
same step for whether it inherits it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): narrow the 9.8 exception to the case 9.8 actually measured

The previous commit withdrew an over-broad claim and replaced it with another
one. It said Manual numbering with `PREVENTDUPLICATES=Yes` is "a proven
duplicate-prevention mechanism". §9.8 measured one thing: how a **failed
`Alter`** behaves under that setting. Its own rule says *"Do not apply the
failed-`Alter` observation to a different request identity mechanism"*, and a
crash retry — the case §3.4 was discussing — sends a `Create`.

It overreaches on SKU as well. §9.8 carries no licensed qualification for the
numbering path; the scope clarification beneath it covers a licensed *Journal*
`ACTION="Create"` repeat carrying `REMOTEID` and says in terms that it does not
establish voucher-number identity, the configured numbering method, or other
request shapes.

Both sites now state the result as what it is — for a failed `Alter` on §9.8's
baseline, Manual numbering converts a silent duplicate into a clean rejection —
with request shape, SKU and voucher type named as the three limits. The tuple
stays withdrawn everywhere; §9.8 reports how one failure mode behaves under one
setting, which is a different kind of thing from a mechanism.

A paragraph is added saying why this needed two corrections: **a claim is not
made safe by being a correction.** Withdrawing one over-broad statement is the
moment you are most likely to write another, because the writing feels like
narrowing while the sentence is widening something else.

`IMPROVEMENT_PLAN_2026H2.md` §8.6 carried the same defect twice over: it
prescribed the directional fold with no scope gate, and its NFC deviation then
reinstated the fold unconditionally — the identical shape fixed in the playbook
last commit, in a file the playbook's own fix did not reach. §8.6 now defers to
the playbook's SCOPE GATE and its deviation says explicitly that removing
normalization does not decide whether any fold is permitted on top.

Audited rather than assumed: all three `accepts()` prescription sites across the
tally docs now carry a gate or an exact-codepoint instruction within eight lines.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): withdraw a licence claim I inferred rather than measured

**The NFC/NFD capture is not licensed evidence.** The scope gate added two
commits ago said "only the NFC/NFD row was measured on a licensed 7.1
instance". It was not. `TALLY_PROTOCOL_REFERENCE.md` records that capture as
"2026-08-19, TallyPrime 7.1, port 9001" and says nothing about licence mode;
`tests/fixtures/encoding/PROVENANCE.md` records that day's instance as **EDU**.
I supplied "licensed" from a stored note about what port 9001 usually runs,
not from the document, and then used it as the premise of an argument about
which evidence is qualified.

Correcting it makes the gate **stricter**, not weaker: with that row withdrawn,
**no** row of §9.4b is qualified on a licensed SKU, so there is no licensed
evidence to widen towards at all. The gate's instruction is unchanged — exact
codepoints — and now rests on something the tree actually says.

**Two identity authorities still said "normalised name".** The withdrawal was
carried to four sites and I reported it complete; `IMPLEMENTATION_GUIDE.md`
§3.5 and `TALLY_PROTOCOL_REFERENCE.md`'s `LASTMID` entry were not among them,
and both are exactly where an implementer looks for the readback rule — so an
implementer following the identity guidance rather than the deviation could
still resolve an NFD create onto a distinct pre-existing NFC master. Both now
say normalised never means NFC/NFD-normalised and defer to the scope gate.

Audited rather than counted this time: no `by normalised name` or `by
normalized name` directive remains anywhere in the tally docs.

The lesson is one already written down and not applied here: a recalled fact
is not a measured one. A note saying what an instance usually is cannot
qualify a specific capture — only that capture's provenance can.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): exact-only is the right rule for binding and the wrong one for creating

The scope gate said "match on exact codepoints" and stopped there, as though
strictness were automatically safe. It is not, and the failure is in the
opposite direction from the one the gate was written to prevent.

If the connected SKU **does** share the Educational case behaviour, an existing
`FOO` reads as ABSENT for a requested `foo` under exact matching. A step that
creates whatever it finds missing then writes a SECOND master differing only by
case — a duplicate in a client's book, produced by the conservative rule. The
loose rule misbinds; the strict rule duplicates. Neither is safe, and the gate
offered no third option.

It has three outcomes now: **bind** on an exact match; **create** only when no
master differs from the requested name by case or separator alone; otherwise
**refuse and raise it for a human**. A near-collision on an unqualified SKU is
exactly where neither automatic answer is defensible.

The near-collision set is computed with §9.4b's fold used as a **detector**,
never as a binder — and that distinction is what makes it legitimate on an
unqualified SKU. Folding to conclude "something similar exists, stop" needs no
licensed qualification, because the conclusion is a refusal rather than a
write. Unverified evidence can justify caution; it cannot justify action.

Separately, `TALLY_PROTOCOL_REFERENCE.md`'s crash-retry paragraph still called
Manual + `PREVENTDUPLICATES=Yes` a qualified duplicate-rejection mechanism for a
repeated number, which is the generalisation corrected in the guide two commits
ago — the correction had not reached the behavioural authority, which is the
document an implementer trusts most. It now names the failed-`Alter` scope, the
UNVERIFIED `Create` retry and the UNVERIFIED licensed SKU, and says plainly not
to read it as promising a crash retry is safe.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a detector must be wider than a binder, not the same rule reversed

The near-collision guard added last commit reused §9.4b's `accepts()` as its
detector, and `accepts()` is **directional**. For a requested `FOO` against an
existing `foo`, `accepts(FOO, foo)` is false — so the guard misses precisely
the collision it exists to catch, and Phase 4 goes on to create the duplicate
master. The hazard survived inside its own fix.

The detector now folds symmetrically and deliberately over-wide:
case-insensitive both ways, hyphen and space interchangeable both ways,
leading and trailing whitespace ignored, internal whitespace runs collapsed.

Several of those rows are UNVERIFIED as *matching* behaviour, and that is the
point rather than an oversight. **An unverified equivalence cannot justify a
write, but it is ample reason to stop and ask.** The asymmetry in consequences
sets the direction: a detector that misses a collision creates a duplicate in a
client's book, while a detector that over-fires costs one question to a human.
Fail toward the question.

That generalises past this guard — wherever the same fold serves two purposes,
the safe width differs by purpose, and reusing one predicate for both silently
adopts the wrong width for one of them.

Also: the licence correction cited `tests/fixtures/encoding/PROVENANCE.md`,
which exists at no such path. The evidence is at
`src-tauri/crates/bridge-tally-protocol/tests/fixtures/encoding/PROVENANCE.md`,
verified present. I took the short path from the review comment that raised the
finding instead of resolving it — in a commit whose whole subject was citing
provenance accurately.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): the guard ran after the write it was meant to prevent

Five findings, three P1, and the first one voids the guard entirely.

**The master-creation guard sat inside step 4, which begins "after counters
accept".** It ran after dispatch. By then `ACTION="Create"` against a name
Tally considers matching has already returned `CREATED=0, ALTERED=1` and
overwritten the existing master with the retry payload (guide §3.6, plan §8.5).
A refusal raised during readback protects nothing — the client's master is
already gone. It is now step **3a**, on the intent, before any import is sent.

**And the harm was described wrongly, in the safer direction.** The guard said a
near-collision "makes a SECOND master differing only by case". The measured
behaviour is an overwrite in place: the existing master's group, opening balance
and GST registration are replaced by whatever the new payload carried. A
duplicate is visible in a ledger list. An overwrite is not.

**The detector omitted canonical equivalence.** §9.4b's exact-codepoint result
came from an EDU instance, so a licensed SKU that folds NFC/NFD is not excluded
— and an NFD request beside an existing NFC master would pass exact lookup *and*
the detector, and overwrite it. Canonical equivalents now collide. This does not
reintroduce NFC normalisation into matching: the binder still compares exact
codepoints. One folds to refuse, the other folds to write, and only the second
needs evidence.

**A read receipt cannot qualify a write predicate.** The gate offered "where the
compatibility result for the connected instance says it holds" as a widening
route. `compatibility/README` defines that evidence as a live-**read** receipt
and says it "never establishes ... any write behavior". Withdrawn here, in Phase
2 item 5, and in the plan's §8.6, all of which carried it.

**What does qualify, now that #288 has landed: §9.4d.** It re-ran §9.4b's method
on licensed 7.1 silver with `education_mode=false`, importing vouchers naming
folded spellings and reading the **day book** back to see which master each
posted against. That is observed write behaviour on the SKU this project writes
to — for **ledgers**. So ledgers on licensed 7.1 match under §9.4d's rows, and
every other master type still matches on exact codepoints, because §9.4d
measured nothing about them.

**§9.5 was carrying implementation policy under a `VERIFIED` marker.** The
measurement is `LASTMID=0`; "which name rule applies" is a choice. A reader
collecting VERIFIED claims would have collected one that was never measured. The
prescription is now a separate block that says so — the evidence marker governs
the paragraph it opens, and a prescription placed under it inherits a confidence
it has not earned.

Surface resealed after the rebase onto #288; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a measured row is directional, and its reverse is not measured

Two findings, opposite in direction, and together they are the binder/detector
distinction working as written — I had applied §9.4d's slash row to neither.

**The detector omitted slash.** §9.4d records a **slash supplied against a
space-bearing master** as MATCHED on licensed 7.1, so a requested `A/B` reaches
an existing `A B`. A detector folding only hyphen and space lets that through
to a `Create` that overwrites the master. Slash is in the detector now, both
ways, and the paragraph says why it is easy to omit: §9.4b never sent one, so
the row exists only in §9.4d and a reader working from the older table will not
find it.

**The binder must NOT fold slash symmetrically.** §9.4d's rows are directional
as written — the left column is what the integration supplies, the right is
what the master carries. `a slash where the master has a space` is MATCHED; the
reverse, supplying a space against a master carrying `/`, has **no row** and is
UNVERIFIED. Hyphen is the exception that proves the shape: §9.4d carries *both*
directions for it as two separate rows, which is why both may be bound.

So the rule now stated in the gate: **the reverse of a measured row is not a
measured row.** Reading that table as a set of equivalences rather than a set of
directed observations is how an unmeasured direction gets bound — and it is the
same mistake §9.4b's own text warns about for the hyphen case, one table later.

The two findings sit either side of a line this PR already drew: the detector
folds wide because it concludes in a refusal, the binder folds only what was
measured because it concludes in a write. Getting slash wrong in both directions
at once is what made that concrete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(tally): reseal the surface after rebasing onto #310

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): a short run of X is data, and decode the capture as UTF-8

Two of the three findings left open when #303 was merged too early. The
third is recorded with its reproduction rather than rushed.

**A bare `X` or `XX` was copied into the fixture verbatim (P1).** The
all-`X` branch treated any token of nothing but `X` as a masking convention
and returned it unchanged — which also bypassed `reserve_source_tokens`, the
one check that exists to stop source text reaching a fixture.
`bank_statement_import` requires `[Xx]{4,}\d*` before it will call something
a masked account, so below four the run is a customer value: an initial, for
instance. Now gated on that same threshold, named `MASK_MIN_XS` beside the
alphabets so the two files cannot drift apart. `XXXXXXXX1234` still keeps
its run and fabricates the digits, which is the case the run exists for.

**The capture was decoded with the host locale (P2).** `pdftotext` emits
UTF-8; `read_text()` without an encoding uses the locale, so on a Windows
Python whose locale is not UTF-8 a raw `Café` becomes mojibake with extra
code points and Indic bytes raise `UnicodeDecodeError` before sanitisation
runs at all. Neither existing check reaches that boundary — CI is
ubuntu-only, and the Unicode cases call `_scrub_plain` with strings that are
already decoded. Pinned to UTF-8 on the read; the write already was.

**Not fixed: the cross-token key collision (P2), recorded instead.**
`_taken` keeps fabricated *tokens* distinct, but the reader concatenates
tokens and strips whitespace, so:

    source 'ACD'  -> 'ZZZ'        key 'ZZZ'
    source 'A CC' -> 'Z' + 'ZZ'   key 'ZZZ'    one mapping row

It is systematic, not rare — the counter is per shape, so the first token of
every shape starts at the alphabet's first letter. Fixing it properly means
the fabricated set must be uniquely decodable after whitespace removal,
which is a change to the fabricator rather than a guard bolted onto it. The
consequence is a fixture that could merge two parties and so fail to catch a
mapping-identity regression for that pair — worth fixing, not worth rushing
into a data-safety tool. The reachable case is in the test file with its
reproduction so it cannot quietly get worse.

Reverting the mask threshold makes the suite red on the first assertion.
Fixture bytes unchanged; neither file is pinned, so no reseal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): classify the masking shape per token, not per character

`MASK_MIN_XS` decided "is this X part of the masking convention?" one
character at a time. A token mixing X with anything else therefore made the
free-position list nonempty, skipped the all-X branch entirely, and carried
its own X straight through: `XAVIER` sanitised to `XZZZZZ`, `ABXXCD` to
`ZZXXZZ`. Those X characters are letters a customer typed, and they reached a
fixture built for a public repository — the precise leak the branch below
them was added to stop, through a narrower door.

The classification is now made once, for the whole token, against the pattern
the parsers actually use: `bank_statement_import` calls something a masked
account only when it matches `[Xx]{4,}\d*`. Inside that shape an X is
structure and survives; everywhere else an X is data and is fabricated like
any other letter.

This also fixes a second defect in the same expression. The old test was
`character != "X"`, uppercase only, so a lowercase mask was not recognised at
all and `xxxx5678` was destroyed to `zzzz1111` — losing the shape the fixture
exists to preserve. The pattern is `[Xx]` because the parser's is.

Three tests asserted the old per-character behaviour, one of them requiring
that the trailing X be preserved. They encoded the defect, so they are
rewritten rather than patched:

- the trailing-X assertion now asserts the opposite, with `XAVIER`,
  `ABXXCD`, `MAX` and `X-RAY` named directly;
- the reservation test floods with tokens that merely contain X and asserts a
  real mask keeps its run while none of the flood carries an X through;
- the exhaustion guard moves from `?XX` to single letters. This matters: with
  X positions now free, `?XX` has 20**3 replacements and can never run out,
  so leaving that shape would have turned a live guard into a test that
  cannot fail.

Mutation-checked: six assertions fail against the pre-fix module, reproducing
`XAVIER -> XZZZZZ` and `ABXXCD -> ZZXXZZ` exactly.

**Known gap, stated rather than glossed.** `sbi-bbox-capture.xml` still holds
one `XX111` token generated under the old rule; the corrected rule would
fabricate it. It is a staleness gap, not a leak — `XX` runs appear twenty
times across the real statements as the *bank's* redaction marks, not
customer letters, and the digits beside them were already fabricated. Every
distinct long digit run in both fixtures was cross-checked against four real
statements spanning three banks: zero matches. Regenerating the fixture needs
the source PDF and is not done here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): honour both mask shapes the parsers recognise

Classifying per token rather than per character was right, but it took the
wrong threshold. `bank_statement_import` recognises **two** account-mask
shapes: `[Xx]{4,}\d*` when deciding whether a standalone narration field is an
account, and `[Xx]+\d+` inside a UPI/IMPS reference, where a run as short as
`XX1234` is the bank's masking rather than a customer's letters. Requiring four
Xs everywhere fabricated `XX1234` into `ZZ1111`, destroying a shape the fixture
exists to preserve.

`_is_mask` is now the union, and still a whole-token test, which is what keeps
`XAVIER`, `ABXXCD` and `MAX` out — an X is structure only when the token is
nothing but a mask, and the short form additionally requires the digits that
make it an account reference rather than an initial. Twelve shapes checked; all
twelve land correctly.

This also settles the staleness gap named in the previous commit: the `XX111`
already in `sbi-bbox-capture.xml` is a mask under the parser's own rule, so it
was never stale.

**The end-to-end check was contradicting the unit cases.** `identifying_tokens`
drops tokens shorter than `IDENTIFYING_LENGTH`, and the survivor set subtracted
every pure-X token as deliberate — so a surviving `X`, `XX` or `XXX`, which the
unit cases define as a leak, was invisible to the artifact check and excused if
seen. The length exclusion exists because a one-digit token has nine possible
replacements and reserving them all starves the allocator; that reasoning is
about digits, not about X. Short tokens containing an X are now included, and
only tokens the parsers would actually call a mask are subtracted.

**The first version of this fix had no test.** Reverting `_is_mask` to the
four-X rule left the whole suite green — the new branch was asserted nowhere,
which is the failure this repository keeps writing down and I reproduced while
fixing someone else's instance of it. Three rows now cover the short form, and
the assertion itself was wrong on first writing: it checked for a fixed-length
run of leading Xs, which is false for `X99`. It now requires every X position to
survive verbatim and every digit position to be fabricated.

Mutation-checked in both directions: green with the union predicate, three
named failures without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): the short mask is an IMPS convention, not a global one

Scoping the mask decision per token was right; scoping it to the whole file was
not. `bank_statement_import` recognises `[Xx]+\d+` **only** inside an `IMPS/`
component, behind an alphabetic prefix and hyphens. `_is_mask` applied it
everywhere, so `X99` in any field at all was classified as masking and
`_fake_token` returned `X11` — carrying a customer's `X` into a public fixture.

That is the defect this function was written to fix, reintroduced one revision
later by widening the rule past the parser it mirrors. The first version decided
per character and leaked; the second decided per token but ignored context and
leaked again, in a narrower place.

The asymmetry worth stating, because it decides every future version of this
question: **a sanitiser may be narrower than the parser — the cost is a
fabricated mask shape — but never wider, because the cost there is a customer
character preserved verbatim.** Given a doubt about scope, take the narrower
reading.

`_scrub_plain` already holds the surrounding field, so the context the token
cannot carry is available one frame up and is passed down. The memo is keyed on
`(token, in_imps)` so the same token cannot be cached under the wrong scope.

Mutation-checked in **both** directions, which the previous version's tests
could not do: making the short form global again fails the three
outside-an-IMPS-field cases, and removing it entirely fails the three
inside-an-IMPS-field cases. A rule with a scope needs a test on each side of
the boundary, or it only ever proves one half of itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a fold belongs at lookup, never in the key — and the race is detected, not prevented

Three findings, all P1, and two of them are the same mistake at two phases.

**Folding a name to build a KEY destroys a row.** A catalogue holding both
`Alpha-Beta` and `Alpha Beta` holds two masters Tally keeps apart. Routing the
key through a fold collapses them into one mirror row before any ambiguity
handling can run — the second row is not ambiguous, it is *gone*, and nothing
downstream can recover a distinction destroyed on the way in. The same happens
to a case-differing pair under the directional predicate.

Both Phase 2 and Phase 4 now say it: **store rows under their exact codepoints;
fold only when resolving a request against those rows.** A fold reaching more
than one row is then an ambiguity to surface, which is only possible because
both rows still exist. This is the read-side twin of the binder/detector rule
already in this document — where a fold sits decides what it can destroy.

**The pre-dispatch guard cannot close its own window, and saying so is the
fix.** Another Tally client or an operator can create a colliding master between
the pre-read and the dispatch; §3.6's overwrite then happens anyway. Bridge's
single-writer actor owns Bridge's writes, not Tally's, and no amount of reading
earlier removes that.

What closes it is that the gateway reports the collision where it cannot hide:
a `Create` that finds a match returns **`CREATED=0, ALTERED=1`**. A create
dispatched from step 3a must therefore assert `CREATED=1`, and `ALTERED=1` is
not a success with a different label — it means a master that existed at
dispatch time has been overwritten with this payload. Alarm, halt, restore from
the pre-image; never a promotion to CONFIRMED.

Stated as detection rather than prevention on purpose. The guard narrows the
window to the dispatch itself; the counter assertion is what makes losing that
race loud instead of silent. A guard that claimed to eliminate the race would be
the more dangerous document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): a short run of X is data, and decode the capture as UTF-8

Two of the three findings left open when #303 was merged too early. The
third is recorded with its reproduction rather than rushed.

**A bare `X` or `XX` was copied into the fixture verbatim (P1).** The
all-`X` branch treated any token of nothing but `X` as a masking convention
and returned it unchanged — which also bypassed `reserve_source_tokens`, the
one check that exists to stop source text reaching a fixture.
`bank_statement_import` requires `[Xx]{4,}\d*` before it will call something
a masked account, so below four the run is a customer value: an initial, for
instance. Now gated on that same threshold, named `MASK_MIN_XS` beside the
alphabets so the two files cannot drift apart. `XXXXXXXX1234` still keeps
its run and fabricates the digits, which is the case the run exists for.

**The capture was decoded with the host locale (P2).** `pdftotext` emits
UTF-8; `read_text()` without an encoding uses the locale, so on a Windows
Python whose locale is not UTF-8 a raw `Café` becomes mojibake with extra
code points and Indic bytes raise `UnicodeDecodeError` before sanitisation
runs at all. Neither existing check reaches that boundary — CI is
ubuntu-only, and the Unicode cases call `_scrub_plain` with strings that are
already decoded. Pinned to UTF-8 on the read; the write already was.

**Not fixed: the cross-token key collision (P2), recorded instead.**
`_taken` keeps fabricated *tokens* distinct, but the reader concatenates
tokens and strips whitespace, so:

    source 'ACD'  -> 'ZZZ'        key 'ZZZ'
    source 'A CC' -> 'Z' + 'ZZ'   key 'ZZZ'    one mapping row

It is systematic, not rare — the counter is per shape, so the first token of
every shape starts at the alphabet's first letter. Fixing it properly means
the fabricated set must be uniquely decodable after whitespace removal,
which is a change to the fabricator rather than a guard bolted onto it. The
consequence is a fixture that could merge two parties and so fail to catch a
mapping-identity regression for that pair — worth fixing, not worth rushing
into a data-safety tool. The reachable case is in the test file with its
reproduction so it cannot quietly get worse.

Reverting the mask threshold makes the suite red on the first assertion.
Fixture bytes unchanged; neither file is pinned, so no reseal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): classify the masking shape per token, not per character

`MASK_MIN_XS` decided "is this X part of the masking convention?" one
character at a time. A token mixing X with anything else therefore made the
free-position list nonempty, skipped the all-X branch entirely, and carried
its own X straight through: `XAVIER` sanitised to `XZZZZZ`, `ABXXCD` to
`ZZXXZZ`. Those X characters are letters a customer typed, and they reached a
fixture built for a public repository — the precise leak the branch below
them was added to stop, through a narrower door.

The classification is now made once, for the whole token, against the pattern
the parsers actually use: `bank_statement_import` calls something a masked
account only when it matches `[Xx]{4,}\d*`. Inside that shape an X is
structure and survives; everywhere else an X is data and is fabricated like
any other letter.

This also fixes a second defect in the same expression. The old test was
`character != "X"`, uppercase only, so a lowercase mask was not recognised at
all and `xxxx5678` was destroyed to `zzzz1111` — losing the shape the fixture
exists to preserve. The pattern is `[Xx]` because the parser's is.

Three tests asserted the old per-character behaviour, one of them requiring
that the trailing X be preserved. They encoded the defect, so they are
rewritten rather than patched:

- the trailing-X assertion now asserts the opposite, with `XAVIER`,
  `ABXXCD`, `MAX` and `X-RAY` named directly;
- the reservation test floods with tokens that merely contain X and asserts a
  real mask keeps its run while none of the flood carries an X through;
- the exhaustion guard moves from `?XX` to single letters. This matters: with
  X positions now free, `?XX` has 20**3 replacements and can never run out,
  so leaving that shape would have turned a live guard into a test that
  cannot fail.

Mutation-checked: six assertions fail against the pre-fix module, reproducing
`XAVIER -> XZZZZZ` and `ABXXCD -> ZZXXZZ` exactly.

**Known gap, stated rather than glossed.** `sbi-bbox-capture.xml` still holds
one `XX111` token generated under the old rule; the corrected rule would
fabricate it. It is a staleness gap, not a leak — `XX` runs appear twenty
times across the real statements as the *bank's* redaction marks, not
customer letters, and the digits beside them were already fabricated. Every
distinct long digit run in both fixtures was cross-checked against four real
statements spanning three banks: zero matches. Regenerating the fixture needs
the source PDF and is not done here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): honour both mask shapes the parsers recognise

Classifying per token rather than per character was right, but it took the
wrong threshold. `bank_statement_import` recognises **two** account-mask
shapes: `[Xx]{4,}\d*` when deciding whether a standalone narration field is an
account, and `[Xx]+\d+` inside a UPI/IMPS reference, where a run as short as
`XX1234` is the bank's masking rather than a customer's letters. Requiring four
Xs everywhere fabricated `XX1234` into `ZZ1111`, destroying a shape the fixture
exists to preserve.

`_is_mask` is now the union, and still a whole-token test, which is what keeps
`XAVIER`, `ABXXCD` and `MAX` out — an X is structure only when the token is
nothing but a mask, and the short form additionally requires the digits that
make it an account reference rather than an initial. Twelve shapes checked; all
twelve land correctly.

This also settles the staleness gap named in the previous commit: the `XX111`
already in `sbi-bbox-capture.xml` is a mask under the parser's own rule, so it
was never stale.

**The end-to-end check was contradicting the unit cases.** `identifying_tokens`
drops tokens shorter than `IDENTIFYING_LENGTH`, and the survivor set subtracted
every pure-X token as deliberate — so a surviving `X`, `XX` or `XXX`, which the
unit cases define as a leak, was invisible to the artifact check and excused if
seen. The length exclusion exists because a one-digit token has nine possible
replacements and reserving them all starves the allocator; that reasoning is
about digits, not about X. Short tokens containing an X are now included, and
only tokens the parsers would actually call a mask are subtracted.

**The first version of this fix had no test.** Reverting `_is_mask` to the
four-X rule left the whole suite green — the new branch was asserted nowhere,
which …
lamemustafa added a commit that referenced this pull request Sep 15, 2026
* docs(tally): four findings left open when #306 was merged too early

These arrived on #306 after it was merged. Two are corrections to text I
wrote, and one is the same failure in two places: a qualification stated in
prose while the thing beside it does the unqualified thing anyway.

**The case clause was symmetric while the note admitted it was not.**
`ascii_lower(candidate) == ascii_lower(tally_name)` also accepts an
UPPERCASE candidate against a lowercase master, a direction never sent — so
the symmetric form asserts a second experiment, exactly as a canonical form
does for the separator. The note below it said so and the predicate kept the
clause. Now `candidate == ascii_lower(tally_name)`, which is what the
capture sent: a lowercase candidate against a master carrying uppercase.
All four clauses are directional and each is one measured result.

**`PROMPT_PLAYBOOK.md` still mandated NFC normalization for name keys**, and
it is the active Phase 2 implementation authority — so the reference could
carry the MEASURED row saying normalising is wrong while the document people
build from told them to normalise. Withdrawn with a dated deviation citing
the measurement: an NFD spelling of a UI-created NFC ledger was rejected
with `EXCEPTIONS=1` while the NFC spelling created it, and an NFD create
read back with identical codepoints. Case folding and the non-Latin fixtures
stay; normalisation goes.

**`IMPLEMENTATION_GUIDE.md` §3.4 kept the fingerprint alive as duplicate
prevention.** My sentence — "it is still true everywhere §3.3a does not
reach" — preserved the withdrawn mandate in every case §3.3a excludes, while
§3.4a of the same patch says the tuple cannot distinguish a retry from a
legitimate identical payment and so prevents no duplicate anywhere. Two
paragraphs of one patch contradicting each other is how a withdrawn mandate
comes back. What is true outside §3.3a's reach is that there is no proven
mechanism there at all, which is narrower and less comfortable.

**`IMPROVEMENT_PLAN_2026H2.md` cited both sections wrongly.** The signal
table is `IMPLEMENTATION_GUIDE.md` §3.4a, not §6.2 (Unicode round-trip), and
the reference's `REMOTEID` rule is §9.3 — `§3.3a` is a section of the guide,
not of the reference, so the citation pointed at a section that does not
exist. A supersession note that lands readers away from the decision it
claims to identify makes the old mandate harder to disambiguate, not easier.

Reference resealed: 1 changed file, pins unchanged at 211.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): directional case fold, NFC withdrawal reaches every site

Five review findings, docs-only.

Defect A (2 findings, P1): PROMPT_PLAYBOOK.md Phase 2 item 5 prescribed
a symmetric case-insensitive collation for name keys. §9.4b's
accepts(candidate, tally_name) predicate only measured a lowercase
candidate against an uppercase master; the reverse was never sent. A
symmetric fold accepts that unverified direction and can bind a
voucher to the wrong master. Restated as the directional predicate.

Defect B (2 findings, P1): the NFC/NFD withdrawal did not reach three
other sites that all instructed matching or reading masters back by
'normalized name' without excluding NFC, or flagged NFC normalization
only when applied asymmetrically:
- PROMPT_PLAYBOOK.md Phase 2 item 5 (paired with the case fix above)
- PROMPT_PLAYBOOK.md Phase 4 step 4 (readback verification)
- PROMPT_PLAYBOOK.md Phase 2 adversarial review item 4
- IMPROVEMENT_PLAN_2026H2.md §8.6
Each now carries a dated deviation note pointing at
TALLY_PROTOCOL_REFERENCE.md §9.4b, in the style already used for the
existing 2026-09-11 fingerprint deviation.

Over-correction (1 finding, P2): IMPLEMENTATION_GUIDE.md §3.4a claimed
no proven duplicate-prevention mechanism exists outside §3.3a's
REMOTEID path. TALLY_PROTOCOL_REFERENCE.md §9.8 is VERIFIED that Manual
numbering with PREVENTDUPLICATES=Yes cleanly rejects a duplicate
(CREATED=0, ALTERED=0, EXCEPTIONS=1) where Automatic numbering silently
creates one — a second proven mechanism, already noted in §3.3 of the
same file. Narrowed the claim to what's actually unproven (an automatic
dedupe decision from the fingerprint tuple) while keeping the correct
part: the fingerprint must never auto-suppress.

No code changes. TALLY_PROTOCOL_REFERENCE.md (pinned) is untouched;
compatibility surface pin count unchanged at 211. Section-number gate
scripts pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): scope the name fold to its baseline, keep the one proven dedupe

Three findings left open on #314, each checked against the tree before
being accepted.

**The name fold is prescribed outside the SKU it was measured on.**
Phase 4's readback step told implementers to match masters with §9.4b's
`accepts()` predicate. §9.4b's case-folding and hyphen-for-space rows
carry no licensed-SKU qualification, so they inherit §0's Edit Log 7.0
Educational baseline; the only row measured on a licensed 7.1 instance is
NFC/NFD, and it points the other way — exact codepoints. Phase 4 runs
against licensed TallyPrime. Applying an Educational-baseline fold there
can bind a write to an account Tally keeps distinct. Phase 4 now matches
on exact codepoints and widens only where a licensed capture or the
connected instance's compatibility result qualifies the predicate.

**The fingerprint correction stopped at the plan.** IMPROVEMENT_PLAN was
repointed to §3.4a but Phase 4 item 6 still sent implementers to
IMPLEMENTATION_GUIDE §6.2 — the Unicode round-trip result, which says
nothing about fingerprints — and therefore away from the signal table and
its automatic-suppression constraints.

**Withdrawing the tuple withdrew a mechanism it had no claim over.**
§3.4's "there is no proven duplicate-prevention mechanism there at all"
is absolute, and false: §9.8 is VERIFIED that Manual numbering with
`PREVENTDUPLICATES=Yes` preserves the supplied number and rejects a
duplicate cleanly (`CREATED=0, ALTERED=0, EXCEPTIONS=1`). The claim is
now scoped to what lies outside both §3.3a's REMOTEID path and §9.8's,
with a sentence saying explicitly that §9.8 supplies a different
mechanism rather than rehabilitating the fingerprint — the tuple stays
withdrawn everywhere.

Surface resealed (rehash → seal → repoint) after the rebase onto #289;
gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): one name-matching rule per step, and let it be the gated one

The scope gate added in `40dee282` was inserted *between* two directives
that still prescribed the fold unconditionally, so Phase 4 step 4 stated
its name-matching rule three times: unqualified before the gate, gated,
and unqualified again in the closing deviation. An implementer reading to
the end of the step finds the fold reinstated. Codex flagged the third; the
first was the same defect and is fixed with it.

Step 4 now names the gate as the only matching rule in the step, and the
NFC deviation says explicitly that it *removes* normalization without
widening what remains — on an unqualified licensed SKU, exact codepoints
and nothing else.

Phase 2 item 5 carried the same unqualified prescription and was not
flagged. Fixed anyway: Phase 2 reads a licensed instance too, and a read is
the worse place to be wrong, because the mirror rows built there are what
later binding decisions resolve against — a fold that merges two masters on
read merges them everywhere downstream.

The lesson is the doc-level form of "a fix that adds a branch must assert
on the new branch": a qualifier inserted between two unqualified statements
qualifies neither. Add the gate, then audit every sibling directive in the
same step for whether it inherits it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): narrow the 9.8 exception to the case 9.8 actually measured

The previous commit withdrew an over-broad claim and replaced it with another
one. It said Manual numbering with `PREVENTDUPLICATES=Yes` is "a proven
duplicate-prevention mechanism". §9.8 measured one thing: how a **failed
`Alter`** behaves under that setting. Its own rule says *"Do not apply the
failed-`Alter` observation to a different request identity mechanism"*, and a
crash retry — the case §3.4 was discussing — sends a `Create`.

It overreaches on SKU as well. §9.8 carries no licensed qualification for the
numbering path; the scope clarification beneath it covers a licensed *Journal*
`ACTION="Create"` repeat carrying `REMOTEID` and says in terms that it does not
establish voucher-number identity, the configured numbering method, or other
request shapes.

Both sites now state the result as what it is — for a failed `Alter` on §9.8's
baseline, Manual numbering converts a silent duplicate into a clean rejection —
with request shape, SKU and voucher type named as the three limits. The tuple
stays withdrawn everywhere; §9.8 reports how one failure mode behaves under one
setting, which is a different kind of thing from a mechanism.

A paragraph is added saying why this needed two corrections: **a claim is not
made safe by being a correction.** Withdrawing one over-broad statement is the
moment you are most likely to write another, because the writing feels like
narrowing while the sentence is widening something else.

`IMPROVEMENT_PLAN_2026H2.md` §8.6 carried the same defect twice over: it
prescribed the directional fold with no scope gate, and its NFC deviation then
reinstated the fold unconditionally — the identical shape fixed in the playbook
last commit, in a file the playbook's own fix did not reach. §8.6 now defers to
the playbook's SCOPE GATE and its deviation says explicitly that removing
normalization does not decide whether any fold is permitted on top.

Audited rather than assumed: all three `accepts()` prescription sites across the
tally docs now carry a gate or an exact-codepoint instruction within eight lines.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): withdraw a licence claim I inferred rather than measured

**The NFC/NFD capture is not licensed evidence.** The scope gate added two
commits ago said "only the NFC/NFD row was measured on a licensed 7.1
instance". It was not. `TALLY_PROTOCOL_REFERENCE.md` records that capture as
"2026-08-19, TallyPrime 7.1, port 9001" and says nothing about licence mode;
`tests/fixtures/encoding/PROVENANCE.md` records that day's instance as **EDU**.
I supplied "licensed" from a stored note about what port 9001 usually runs,
not from the document, and then used it as the premise of an argument about
which evidence is qualified.

Correcting it makes the gate **stricter**, not weaker: with that row withdrawn,
**no** row of §9.4b is qualified on a licensed SKU, so there is no licensed
evidence to widen towards at all. The gate's instruction is unchanged — exact
codepoints — and now rests on something the tree actually says.

**Two identity authorities still said "normalised name".** The withdrawal was
carried to four sites and I reported it complete; `IMPLEMENTATION_GUIDE.md`
§3.5 and `TALLY_PROTOCOL_REFERENCE.md`'s `LASTMID` entry were not among them,
and both are exactly where an implementer looks for the readback rule — so an
implementer following the identity guidance rather than the deviation could
still resolve an NFD create onto a distinct pre-existing NFC master. Both now
say normalised never means NFC/NFD-normalised and defer to the scope gate.

Audited rather than counted this time: no `by normalised name` or `by
normalized name` directive remains anywhere in the tally docs.

The lesson is one already written down and not applied here: a recalled fact
is not a measured one. A note saying what an instance usually is cannot
qualify a specific capture — only that capture's provenance can.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): exact-only is the right rule for binding and the wrong one for creating

The scope gate said "match on exact codepoints" and stopped there, as though
strictness were automatically safe. It is not, and the failure is in the
opposite direction from the one the gate was written to prevent.

If the connected SKU **does** share the Educational case behaviour, an existing
`FOO` reads as ABSENT for a requested `foo` under exact matching. A step that
creates whatever it finds missing then writes a SECOND master differing only by
case — a duplicate in a client's book, produced by the conservative rule. The
loose rule misbinds; the strict rule duplicates. Neither is safe, and the gate
offered no third option.

It has three outcomes now: **bind** on an exact match; **create** only when no
master differs from the requested name by case or separator alone; otherwise
**refuse and raise it for a human**. A near-collision on an unqualified SKU is
exactly where neither automatic answer is defensible.

The near-collision set is computed with §9.4b's fold used as a **detector**,
never as a binder — and that distinction is what makes it legitimate on an
unqualified SKU. Folding to conclude "something similar exists, stop" needs no
licensed qualification, because the conclusion is a refusal rather than a
write. Unverified evidence can justify caution; it cannot justify action.

Separately, `TALLY_PROTOCOL_REFERENCE.md`'s crash-retry paragraph still called
Manual + `PREVENTDUPLICATES=Yes` a qualified duplicate-rejection mechanism for a
repeated number, which is the generalisation corrected in the guide two commits
ago — the correction had not reached the behavioural authority, which is the
document an implementer trusts most. It now names the failed-`Alter` scope, the
UNVERIFIED `Create` retry and the UNVERIFIED licensed SKU, and says plainly not
to read it as promising a crash retry is safe.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a detector must be wider than a binder, not the same rule reversed

The near-collision guard added last commit reused §9.4b's `accepts()` as its
detector, and `accepts()` is **directional**. For a requested `FOO` against an
existing `foo`, `accepts(FOO, foo)` is false — so the guard misses precisely
the collision it exists to catch, and Phase 4 goes on to create the duplicate
master. The hazard survived inside its own fix.

The detector now folds symmetrically and deliberately over-wide:
case-insensitive both ways, hyphen and space interchangeable both ways,
leading and trailing whitespace ignored, internal whitespace runs collapsed.

Several of those rows are UNVERIFIED as *matching* behaviour, and that is the
point rather than an oversight. **An unverified equivalence cannot justify a
write, but it is ample reason to stop and ask.** The asymmetry in consequences
sets the direction: a detector that misses a collision creates a duplicate in a
client's book, while a detector that over-fires costs one question to a human.
Fail toward the question.

That generalises past this guard — wherever the same fold serves two purposes,
the safe width differs by purpose, and reusing one predicate for both silently
adopts the wrong width for one of them.

Also: the licence correction cited `tests/fixtures/encoding/PROVENANCE.md`,
which exists at no such path. The evidence is at
`src-tauri/crates/bridge-tally-protocol/tests/fixtures/encoding/PROVENANCE.md`,
verified present. I took the short path from the review comment that raised the
finding instead of resolving it — in a commit whose whole subject was citing
provenance accurately.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): the guard ran after the write it was meant to prevent

Five findings, three P1, and the first one voids the guard entirely.

**The master-creation guard sat inside step 4, which begins "after counters
accept".** It ran after dispatch. By then `ACTION="Create"` against a name
Tally considers matching has already returned `CREATED=0, ALTERED=1` and
overwritten the existing master with the retry payload (guide §3.6, plan §8.5).
A refusal raised during readback protects nothing — the client's master is
already gone. It is now step **3a**, on the intent, before any import is sent.

**And the harm was described wrongly, in the safer direction.** The guard said a
near-collision "makes a SECOND master differing only by case". The measured
behaviour is an overwrite in place: the existing master's group, opening balance
and GST registration are replaced by whatever the new payload carried. A
duplicate is visible in a ledger list. An overwrite is not.

**The detector omitted canonical equivalence.** §9.4b's exact-codepoint result
came from an EDU instance, so a licensed SKU that folds NFC/NFD is not excluded
— and an NFD request beside an existing NFC master would pass exact lookup *and*
the detector, and overwrite it. Canonical equivalents now collide. This does not
reintroduce NFC normalisation into matching: the binder still compares exact
codepoints. One folds to refuse, the other folds to write, and only the second
needs evidence.

**A read receipt cannot qualify a write predicate.** The gate offered "where the
compatibility result for the connected instance says it holds" as a widening
route. `compatibility/README` defines that evidence as a live-**read** receipt
and says it "never establishes ... any write behavior". Withdrawn here, in Phase
2 item 5, and in the plan's §8.6, all of which carried it.

**What does qualify, now that #288 has landed: §9.4d.** It re-ran §9.4b's method
on licensed 7.1 silver with `education_mode=false`, importing vouchers naming
folded spellings and reading the **day book** back to see which master each
posted against. That is observed write behaviour on the SKU this project writes
to — for **ledgers**. So ledgers on licensed 7.1 match under §9.4d's rows, and
every other master type still matches on exact codepoints, because §9.4d
measured nothing about them.

**§9.5 was carrying implementation policy under a `VERIFIED` marker.** The
measurement is `LASTMID=0`; "which name rule applies" is a choice. A reader
collecting VERIFIED claims would have collected one that was never measured. The
prescription is now a separate block that says so — the evidence marker governs
the paragraph it opens, and a prescription placed under it inherits a confidence
it has not earned.

Surface resealed after the rebase onto #288; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): a short run of X is data, and decode the capture as UTF-8

Two of the three findings left open when #303 was merged too early. The
third is recorded with its reproduction rather than rushed.

**A bare `X` or `XX` was copied into the fixture verbatim (P1).** The
all-`X` branch treated any token of nothing but `X` as a masking convention
and returned it unchanged — which also bypassed `reserve_source_tokens`, the
one check that exists to stop source text reaching a fixture.
`bank_statement_import` requires `[Xx]{4,}\d*` before it will call something
a masked account, so below four the run is a customer value: an initial, for
instance. Now gated on that same threshold, named `MASK_MIN_XS` beside the
alphabets so the two files cannot drift apart. `XXXXXXXX1234` still keeps
its run and fabricates the digits, which is the case the run exists for.

**The capture was decoded with the host locale (P2).** `pdftotext` emits
UTF-8; `read_text()` without an encoding uses the locale, so on a Windows
Python whose locale is not UTF-8 a raw `Café` becomes mojibake with extra
code points and Indic bytes raise `UnicodeDecodeError` before sanitisation
runs at all. Neither existing check reaches that boundary — CI is
ubuntu-only, and the Unicode cases call `_scrub_plain` with strings that are
already decoded. Pinned to UTF-8 on the read; the write already was.

**Not fixed: the cross-token key collision (P2), recorded instead.**
`_taken` keeps fabricated *tokens* distinct, but the reader concatenates
tokens and strips whitespace, so:

    source 'ACD'  -> 'ZZZ'        key 'ZZZ'
    source 'A CC' -> 'Z' + 'ZZ'   key 'ZZZ'    one mapping row

It is systematic, not rare — the counter is per shape, so the first token of
every shape starts at the alphabet's first letter. Fixing it properly means
the fabricated set must be uniquely decodable after whitespace removal,
which is a change to the fabricator rather than a guard bolted onto it. The
consequence is a fixture that could merge two parties and so fail to catch a
mapping-identity regression for that pair — worth fixing, not worth rushing
into a data-safety tool. The reachable case is in the test file with its
reproduction so it cannot quietly get worse.

Reverting the mask threshold makes the suite red on the first assertion.
Fixture bytes unchanged; neither file is pinned, so no reseal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): classify the masking shape per token, not per character

`MASK_MIN_XS` decided "is this X part of the masking convention?" one
character at a time. A token mixing X with anything else therefore made the
free-position list nonempty, skipped the all-X branch entirely, and carried
its own X straight through: `XAVIER` sanitised to `XZZZZZ`, `ABXXCD` to
`ZZXXZZ`. Those X characters are letters a customer typed, and they reached a
fixture built for a public repository — the precise leak the branch below
them was added to stop, through a narrower door.

The classification is now made once, for the whole token, against the pattern
the parsers actually use: `bank_statement_import` calls something a masked
account only when it matches `[Xx]{4,}\d*`. Inside that shape an X is
structure and survives; everywhere else an X is data and is fabricated like
any other letter.

This also fixes a second defect in the same expression. The old test was
`character != "X"`, uppercase only, so a lowercase mask was not recognised at
all and `xxxx5678` was destroyed to `zzzz1111` — losing the shape the fixture
exists to preserve. The pattern is `[Xx]` because the parser's is.

Three tests asserted the old per-character behaviour, one of them requiring
that the trailing X be preserved. They encoded the defect, so they are
rewritten rather than patched:

- the trailing-X assertion now asserts the opposite, with `XAVIER`,
  `ABXXCD`, `MAX` and `X-RAY` named directly;
- the reservation test floods with tokens that merely contain X and asserts a
  real mask keeps its run while none of the flood carries an X through;
- the exhaustion guard moves from `?XX` to single letters. This matters: with
  X positions now free, `?XX` has 20**3 replacements and can never run out,
  so leaving that shape would have turned a live guard into a test that
  cannot fail.

Mutation-checked: six assertions fail against the pre-fix module, reproducing
`XAVIER -> XZZZZZ` and `ABXXCD -> ZZXXZZ` exactly.

**Known gap, stated rather than glossed.** `sbi-bbox-capture.xml` still holds
one `XX111` token generated under the old rule; the corrected rule would
fabricate it. It is a staleness gap, not a leak — `XX` runs appear twenty
times across the real statements as the *bank's* redaction marks, not
customer letters, and the digits beside them were already fabricated. Every
distinct long digit run in both fixtures was cross-checked against four real
statements spanning three banks: zero matches. Regenerating the fixture needs
the source PDF and is not done here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): honour both mask shapes the parsers recognise

Classifying per token rather than per character was right, but it took the
wrong threshold. `bank_statement_import` recognises **two** account-mask
shapes: `[Xx]{4,}\d*` when deciding whether a standalone narration field is an
account, and `[Xx]+\d+` inside a UPI/IMPS reference, where a run as short as
`XX1234` is the bank's masking rather than a customer's letters. Requiring four
Xs everywhere fabricated `XX1234` into `ZZ1111`, destroying a shape the fixture
exists to preserve.

`_is_mask` is now the union, and still a whole-token test, which is what keeps
`XAVIER`, `ABXXCD` and `MAX` out — an X is structure only when the token is
nothing but a mask, and the short form additionally requires the digits that
make it an account reference rather than an initial. Twelve shapes checked; all
twelve land correctly.

This also settles the staleness gap named in the previous commit: the `XX111`
already in `sbi-bbox-capture.xml` is a mask under the parser's own rule, so it
was never stale.

**The end-to-end check was contradicting the unit cases.** `identifying_tokens`
drops tokens shorter than `IDENTIFYING_LENGTH`, and the survivor set subtracted
every pure-X token as deliberate — so a surviving `X`, `XX` or `XXX`, which the
unit cases define as a leak, was invisible to the artifact check and excused if
seen. The length exclusion exists because a one-digit token has nine possible
replacements and reserving them all starves the allocator; that reasoning is
about digits, not about X. Short tokens containing an X are now included, and
only tokens the parsers would actually call a mask are subtracted.

**The first version of this fix had no test.** Reverting `_is_mask` to the
four-X rule left the whole suite green — the new branch was asserted nowhere,
which is the failure this repository keeps writing down and I reproduced while
fixing someone else's instance of it. Three rows now cover the short form, and
the assertion itself was wrong on first writing: it checked for a fixed-length
run of leading Xs, which is false for `X99`. It now requires every X position to
survive verbatim and every digit position to be fabricated.

Mutation-checked in both directions: green with the union predicate, three
named failures without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep unknown-outcome recovery read-only across active guidance

* docs(tally): keep unknown outcomes out of dispatchable state

* fix(scripts): bind short masks to captured IMPS field context

* docs(tally): require intent fields before binding existing masters

* fix(scripts): withhold mask role when capture context is omitted

* fix(scripts): retain mask context independently of table furniture

* docs(tally): align scoped qualification and held recovery across plans

* docs: keep slash matching directional

* docs: limit Silver slash comparison direction

* docs(tally): four findings left open when #306 was merged too early

These arrived on #306 after it was merged. Two are corrections to text I
wrote, and one is the same failure in two places: a qualification stated in
prose while the thing beside it does the unqualified thing anyway.

**The case clause was symmetric while the note admitted it was not.**
`ascii_lower(candidate) == ascii_lower(tally_name)` also accepts an
UPPERCASE candidate against a lowercase master, a direction never sent — so
the symmetric form asserts a second experiment, exactly as a canonical form
does for the separator. The note below it said so and the predicate kept the
clause. Now `candidate == ascii_lower(tally_name)`, which is what the
capture sent: a lowercase candidate against a master carrying uppercase.
All four clauses are directional and each is one measured result.

**`PROMPT_PLAYBOOK.md` still mandated NFC normalization for name keys**, and
it is the active Phase 2 implementation authority — so the reference could
carry the MEASURED row saying normalising is wrong while the document people
build from told them to normalise. Withdrawn with a dated deviation citing
the measurement: an NFD spelling of a UI-created NFC ledger was rejected
with `EXCEPTIONS=1` while the NFC spelling created it, and an NFD create
read back with identical codepoints. Case folding and the non-Latin fixtures
stay; normalisation goes.

**`IMPLEMENTATION_GUIDE.md` §3.4 kept the fingerprint alive as duplicate
prevention.** My sentence — "it is still true everywhere §3.3a does not
reach" — preserved the withdrawn mandate in every case §3.3a excludes, while
§3.4a of the same patch says the tuple cannot distinguish a retry from a
legitimate identical payment and so prevents no duplicate anywhere. Two
paragraphs of one patch contradicting each other is how a withdrawn mandate
comes back. What is true outside §3.3a's reach is that there is no proven
mechanism there at all, which is narrower and less comfortable.

**`IMPROVEMENT_PLAN_2026H2.md` cited both sections wrongly.** The signal
table is `IMPLEMENTATION_GUIDE.md` §3.4a, not §6.2 (Unicode round-trip), and
the reference's `REMOTEID` rule is §9.3 — `§3.3a` is a section of the guide,
not of the reference, so the citation pointed at a section that does not
exist. A supersession note that lands readers away from the decision it
claims to identify makes the old mandate harder to disambiguate, not easier.

Reference resealed: 1 changed file, pins unchanged at 211.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): directional case fold, NFC withdrawal reaches every site

Five review findings, docs-only.

Defect A (2 findings, P1): PROMPT_PLAYBOOK.md Phase 2 item 5 prescribed
a symmetric case-insensitive collation for name keys. §9.4b's
accepts(candidate, tally_name) predicate only measured a lowercase
candidate against an uppercase master; the reverse was never sent. A
symmetric fold accepts that unverified direction and can bind a
voucher to the wrong master. Restated as the directional predicate.

Defect B (2 findings, P1): the NFC/NFD withdrawal did not reach three
other sites that all instructed matching or reading masters back by
'normalized name' without excluding NFC, or flagged NFC normalization
only when applied asymmetrically:
- PROMPT_PLAYBOOK.md Phase 2 item 5 (paired with the case fix above)
- PROMPT_PLAYBOOK.md Phase 4 step 4 (readback verification)
- PROMPT_PLAYBOOK.md Phase 2 adversarial review item 4
- IMPROVEMENT_PLAN_2026H2.md §8.6
Each now carries a dated deviation note pointing at
TALLY_PROTOCOL_REFERENCE.md §9.4b, in the style already used for the
existing 2026-09-11 fingerprint deviation.

Over-correction (1 finding, P2): IMPLEMENTATION_GUIDE.md §3.4a claimed
no proven duplicate-prevention mechanism exists outside §3.3a's
REMOTEID path. TALLY_PROTOCOL_REFERENCE.md §9.8 is VERIFIED that Manual
numbering with PREVENTDUPLICATES=Yes cleanly rejects a duplicate
(CREATED=0, ALTERED=0, EXCEPTIONS=1) where Automatic numbering silently
creates one — a second proven mechanism, already noted in §3.3 of the
same file. Narrowed the claim to what's actually unproven (an automatic
dedupe decision from the fingerprint tuple) while keeping the correct
part: the fingerprint must never auto-suppress.

No code changes. TALLY_PROTOCOL_REFERENCE.md (pinned) is untouched;
compatibility surface pin count unchanged at 211. Section-number gate
scripts pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): scope the name fold to its baseline, keep the one proven dedupe

Three findings left open on #314, each checked against the tree before
being accepted.

**The name fold is prescribed outside the SKU it was measured on.**
Phase 4's readback step told implementers to match masters with §9.4b's
`accepts()` predicate. §9.4b's case-folding and hyphen-for-space rows
carry no licensed-SKU qualification, so they inherit §0's Edit Log 7.0
Educational baseline; the only row measured on a licensed 7.1 instance is
NFC/NFD, and it points the other way — exact codepoints. Phase 4 runs
against licensed TallyPrime. Applying an Educational-baseline fold there
can bind a write to an account Tally keeps distinct. Phase 4 now matches
on exact codepoints and widens only where a licensed capture or the
connected instance's compatibility result qualifies the predicate.

**The fingerprint correction stopped at the plan.** IMPROVEMENT_PLAN was
repointed to §3.4a but Phase 4 item 6 still sent implementers to
IMPLEMENTATION_GUIDE §6.2 — the Unicode round-trip result, which says
nothing about fingerprints — and therefore away from the signal table and
its automatic-suppression constraints.

**Withdrawing the tuple withdrew a mechanism it had no claim over.**
§3.4's "there is no proven duplicate-prevention mechanism there at all"
is absolute, and false: §9.8 is VERIFIED that Manual numbering with
`PREVENTDUPLICATES=Yes` preserves the supplied number and rejects a
duplicate cleanly (`CREATED=0, ALTERED=0, EXCEPTIONS=1`). The claim is
now scoped to what lies outside both §3.3a's REMOTEID path and §9.8's,
with a sentence saying explicitly that §9.8 supplies a different
mechanism rather than rehabilitating the fingerprint — the tuple stays
withdrawn everywhere.

Surface resealed (rehash → seal → repoint) after the rebase onto #289;
gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): one name-matching rule per step, and let it be the gated one

The scope gate added in `40dee282` was inserted *between* two directives
that still prescribed the fold unconditionally, so Phase 4 step 4 stated
its name-matching rule three times: unqualified before the gate, gated,
and unqualified again in the closing deviation. An implementer reading to
the end of the step finds the fold reinstated. Codex flagged the third; the
first was the same defect and is fixed with it.

Step 4 now names the gate as the only matching rule in the step, and the
NFC deviation says explicitly that it *removes* normalization without
widening what remains — on an unqualified licensed SKU, exact codepoints
and nothing else.

Phase 2 item 5 carried the same unqualified prescription and was not
flagged. Fixed anyway: Phase 2 reads a licensed instance too, and a read is
the worse place to be wrong, because the mirror rows built there are what
later binding decisions resolve against — a fold that merges two masters on
read merges them everywhere downstream.

The lesson is the doc-level form of "a fix that adds a branch must assert
on the new branch": a qualifier inserted between two unqualified statements
qualifies neither. Add the gate, then audit every sibling directive in the
same step for whether it inherits it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): narrow the 9.8 exception to the case 9.8 actually measured

The previous commit withdrew an over-broad claim and replaced it with another
one. It said Manual numbering with `PREVENTDUPLICATES=Yes` is "a proven
duplicate-prevention mechanism". §9.8 measured one thing: how a **failed
`Alter`** behaves under that setting. Its own rule says *"Do not apply the
failed-`Alter` observation to a different request identity mechanism"*, and a
crash retry — the case §3.4 was discussing — sends a `Create`.

It overreaches on SKU as well. §9.8 carries no licensed qualification for the
numbering path; the scope clarification beneath it covers a licensed *Journal*
`ACTION="Create"` repeat carrying `REMOTEID` and says in terms that it does not
establish voucher-number identity, the configured numbering method, or other
request shapes.

Both sites now state the result as what it is — for a failed `Alter` on §9.8's
baseline, Manual numbering converts a silent duplicate into a clean rejection —
with request shape, SKU and voucher type named as the three limits. The tuple
stays withdrawn everywhere; §9.8 reports how one failure mode behaves under one
setting, which is a different kind of thing from a mechanism.

A paragraph is added saying why this needed two corrections: **a claim is not
made safe by being a correction.** Withdrawing one over-broad statement is the
moment you are most likely to write another, because the writing feels like
narrowing while the sentence is widening something else.

`IMPROVEMENT_PLAN_2026H2.md` §8.6 carried the same defect twice over: it
prescribed the directional fold with no scope gate, and its NFC deviation then
reinstated the fold unconditionally — the identical shape fixed in the playbook
last commit, in a file the playbook's own fix did not reach. §8.6 now defers to
the playbook's SCOPE GATE and its deviation says explicitly that removing
normalization does not decide whether any fold is permitted on top.

Audited rather than assumed: all three `accepts()` prescription sites across the
tally docs now carry a gate or an exact-codepoint instruction within eight lines.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): withdraw a licence claim I inferred rather than measured

**The NFC/NFD capture is not licensed evidence.** The scope gate added two
commits ago said "only the NFC/NFD row was measured on a licensed 7.1
instance". It was not. `TALLY_PROTOCOL_REFERENCE.md` records that capture as
"2026-08-19, TallyPrime 7.1, port 9001" and says nothing about licence mode;
`tests/fixtures/encoding/PROVENANCE.md` records that day's instance as **EDU**.
I supplied "licensed" from a stored note about what port 9001 usually runs,
not from the document, and then used it as the premise of an argument about
which evidence is qualified.

Correcting it makes the gate **stricter**, not weaker: with that row withdrawn,
**no** row of §9.4b is qualified on a licensed SKU, so there is no licensed
evidence to widen towards at all. The gate's instruction is unchanged — exact
codepoints — and now rests on something the tree actually says.

**Two identity authorities still said "normalised name".** The withdrawal was
carried to four sites and I reported it complete; `IMPLEMENTATION_GUIDE.md`
§3.5 and `TALLY_PROTOCOL_REFERENCE.md`'s `LASTMID` entry were not among them,
and both are exactly where an implementer looks for the readback rule — so an
implementer following the identity guidance rather than the deviation could
still resolve an NFD create onto a distinct pre-existing NFC master. Both now
say normalised never means NFC/NFD-normalised and defer to the scope gate.

Audited rather than counted this time: no `by normalised name` or `by
normalized name` directive remains anywhere in the tally docs.

The lesson is one already written down and not applied here: a recalled fact
is not a measured one. A note saying what an instance usually is cannot
qualify a specific capture — only that capture's provenance can.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): exact-only is the right rule for binding and the wrong one for creating

The scope gate said "match on exact codepoints" and stopped there, as though
strictness were automatically safe. It is not, and the failure is in the
opposite direction from the one the gate was written to prevent.

If the connected SKU **does** share the Educational case behaviour, an existing
`FOO` reads as ABSENT for a requested `foo` under exact matching. A step that
creates whatever it finds missing then writes a SECOND master differing only by
case — a duplicate in a client's book, produced by the conservative rule. The
loose rule misbinds; the strict rule duplicates. Neither is safe, and the gate
offered no third option.

It has three outcomes now: **bind** on an exact match; **create** only when no
master differs from the requested name by case or separator alone; otherwise
**refuse and raise it for a human**. A near-collision on an unqualified SKU is
exactly where neither automatic answer is defensible.

The near-collision set is computed with §9.4b's fold used as a **detector**,
never as a binder — and that distinction is what makes it legitimate on an
unqualified SKU. Folding to conclude "something similar exists, stop" needs no
licensed qualification, because the conclusion is a refusal rather than a
write. Unverified evidence can justify caution; it cannot justify action.

Separately, `TALLY_PROTOCOL_REFERENCE.md`'s crash-retry paragraph still called
Manual + `PREVENTDUPLICATES=Yes` a qualified duplicate-rejection mechanism for a
repeated number, which is the generalisation corrected in the guide two commits
ago — the correction had not reached the behavioural authority, which is the
document an implementer trusts most. It now names the failed-`Alter` scope, the
UNVERIFIED `Create` retry and the UNVERIFIED licensed SKU, and says plainly not
to read it as promising a crash retry is safe.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a detector must be wider than a binder, not the same rule reversed

The near-collision guard added last commit reused §9.4b's `accepts()` as its
detector, and `accepts()` is **directional**. For a requested `FOO` against an
existing `foo`, `accepts(FOO, foo)` is false — so the guard misses precisely
the collision it exists to catch, and Phase 4 goes on to create the duplicate
master. The hazard survived inside its own fix.

The detector now folds symmetrically and deliberately over-wide:
case-insensitive both ways, hyphen and space interchangeable both ways,
leading and trailing whitespace ignored, internal whitespace runs collapsed.

Several of those rows are UNVERIFIED as *matching* behaviour, and that is the
point rather than an oversight. **An unverified equivalence cannot justify a
write, but it is ample reason to stop and ask.** The asymmetry in consequences
sets the direction: a detector that misses a collision creates a duplicate in a
client's book, while a detector that over-fires costs one question to a human.
Fail toward the question.

That generalises past this guard — wherever the same fold serves two purposes,
the safe width differs by purpose, and reusing one predicate for both silently
adopts the wrong width for one of them.

Also: the licence correction cited `tests/fixtures/encoding/PROVENANCE.md`,
which exists at no such path. The evidence is at
`src-tauri/crates/bridge-tally-protocol/tests/fixtures/encoding/PROVENANCE.md`,
verified present. I took the short path from the review comment that raised the
finding instead of resolving it — in a commit whose whole subject was citing
provenance accurately.

Surface resealed; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): the guard ran after the write it was meant to prevent

Five findings, three P1, and the first one voids the guard entirely.

**The master-creation guard sat inside step 4, which begins "after counters
accept".** It ran after dispatch. By then `ACTION="Create"` against a name
Tally considers matching has already returned `CREATED=0, ALTERED=1` and
overwritten the existing master with the retry payload (guide §3.6, plan §8.5).
A refusal raised during readback protects nothing — the client's master is
already gone. It is now step **3a**, on the intent, before any import is sent.

**And the harm was described wrongly, in the safer direction.** The guard said a
near-collision "makes a SECOND master differing only by case". The measured
behaviour is an overwrite in place: the existing master's group, opening balance
and GST registration are replaced by whatever the new payload carried. A
duplicate is visible in a ledger list. An overwrite is not.

**The detector omitted canonical equivalence.** §9.4b's exact-codepoint result
came from an EDU instance, so a licensed SKU that folds NFC/NFD is not excluded
— and an NFD request beside an existing NFC master would pass exact lookup *and*
the detector, and overwrite it. Canonical equivalents now collide. This does not
reintroduce NFC normalisation into matching: the binder still compares exact
codepoints. One folds to refuse, the other folds to write, and only the second
needs evidence.

**A read receipt cannot qualify a write predicate.** The gate offered "where the
compatibility result for the connected instance says it holds" as a widening
route. `compatibility/README` defines that evidence as a live-**read** receipt
and says it "never establishes ... any write behavior". Withdrawn here, in Phase
2 item 5, and in the plan's §8.6, all of which carried it.

**What does qualify, now that #288 has landed: §9.4d.** It re-ran §9.4b's method
on licensed 7.1 silver with `education_mode=false`, importing vouchers naming
folded spellings and reading the **day book** back to see which master each
posted against. That is observed write behaviour on the SKU this project writes
to — for **ledgers**. So ledgers on licensed 7.1 match under §9.4d's rows, and
every other master type still matches on exact codepoints, because §9.4d
measured nothing about them.

**§9.5 was carrying implementation policy under a `VERIFIED` marker.** The
measurement is `LASTMID=0`; "which name rule applies" is a choice. A reader
collecting VERIFIED claims would have collected one that was never measured. The
prescription is now a separate block that says so — the evidence marker governs
the paragraph it opens, and a prescription placed under it inherits a confidence
it has not earned.

Surface resealed after the rebase onto #288; gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a measured row is directional, and its reverse is not measured

Two findings, opposite in direction, and together they are the binder/detector
distinction working as written — I had applied §9.4d's slash row to neither.

**The detector omitted slash.** §9.4d records a **slash supplied against a
space-bearing master** as MATCHED on licensed 7.1, so a requested `A/B` reaches
an existing `A B`. A detector folding only hyphen and space lets that through
to a `Create` that overwrites the master. Slash is in the detector now, both
ways, and the paragraph says why it is easy to omit: §9.4b never sent one, so
the row exists only in §9.4d and a reader working from the older table will not
find it.

**The binder must NOT fold slash symmetrically.** §9.4d's rows are directional
as written — the left column is what the integration supplies, the right is
what the master carries. `a slash where the master has a space` is MATCHED; the
reverse, supplying a space against a master carrying `/`, has **no row** and is
UNVERIFIED. Hyphen is the exception that proves the shape: §9.4d carries *both*
directions for it as two separate rows, which is why both may be bound.

So the rule now stated in the gate: **the reverse of a measured row is not a
measured row.** Reading that table as a set of equivalences rather than a set of
directed observations is how an unmeasured direction gets bound — and it is the
same mistake §9.4b's own text warns about for the hyphen case, one table later.

The two findings sit either side of a line this PR already drew: the detector
folds wide because it concludes in a refusal, the binder folds only what was
measured because it concludes in a write. Getting slash wrong in both directions
at once is what made that concrete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(tally): reseal the surface after rebasing onto #310

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): a short run of X is data, and decode the capture as UTF-8

Two of the three findings left open when #303 was merged too early. The
third is recorded with its reproduction rather than rushed.

**A bare `X` or `XX` was copied into the fixture verbatim (P1).** The
all-`X` branch treated any token of nothing but `X` as a masking convention
and returned it unchanged — which also bypassed `reserve_source_tokens`, the
one check that exists to stop source text reaching a fixture.
`bank_statement_import` requires `[Xx]{4,}\d*` before it will call something
a masked account, so below four the run is a customer value: an initial, for
instance. Now gated on that same threshold, named `MASK_MIN_XS` beside the
alphabets so the two files cannot drift apart. `XXXXXXXX1234` still keeps
its run and fabricates the digits, which is the case the run exists for.

**The capture was decoded with the host locale (P2).** `pdftotext` emits
UTF-8; `read_text()` without an encoding uses the locale, so on a Windows
Python whose locale is not UTF-8 a raw `Café` becomes mojibake with extra
code points and Indic bytes raise `UnicodeDecodeError` before sanitisation
runs at all. Neither existing check reaches that boundary — CI is
ubuntu-only, and the Unicode cases call `_scrub_plain` with strings that are
already decoded. Pinned to UTF-8 on the read; the write already was.

**Not fixed: the cross-token key collision (P2), recorded instead.**
`_taken` keeps fabricated *tokens* distinct, but the reader concatenates
tokens and strips whitespace, so:

    source 'ACD'  -> 'ZZZ'        key 'ZZZ'
    source 'A CC' -> 'Z' + 'ZZ'   key 'ZZZ'    one mapping row

It is systematic, not rare — the counter is per shape, so the first token of
every shape starts at the alphabet's first letter. Fixing it properly means
the fabricated set must be uniquely decodable after whitespace removal,
which is a change to the fabricator rather than a guard bolted onto it. The
consequence is a fixture that could merge two parties and so fail to catch a
mapping-identity regression for that pair — worth fixing, not worth rushing
into a data-safety tool. The reachable case is in the test file with its
reproduction so it cannot quietly get worse.

Reverting the mask threshold makes the suite red on the first assertion.
Fixture bytes unchanged; neither file is pinned, so no reseal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): classify the masking shape per token, not per character

`MASK_MIN_XS` decided "is this X part of the masking convention?" one
character at a time. A token mixing X with anything else therefore made the
free-position list nonempty, skipped the all-X branch entirely, and carried
its own X straight through: `XAVIER` sanitised to `XZZZZZ`, `ABXXCD` to
`ZZXXZZ`. Those X characters are letters a customer typed, and they reached a
fixture built for a public repository — the precise leak the branch below
them was added to stop, through a narrower door.

The classification is now made once, for the whole token, against the pattern
the parsers actually use: `bank_statement_import` calls something a masked
account only when it matches `[Xx]{4,}\d*`. Inside that shape an X is
structure and survives; everywhere else an X is data and is fabricated like
any other letter.

This also fixes a second defect in the same expression. The old test was
`character != "X"`, uppercase only, so a lowercase mask was not recognised at
all and `xxxx5678` was destroyed to `zzzz1111` — losing the shape the fixture
exists to preserve. The pattern is `[Xx]` because the parser's is.

Three tests asserted the old per-character behaviour, one of them requiring
that the trailing X be preserved. They encoded the defect, so they are
rewritten rather than patched:

- the trailing-X assertion now asserts the opposite, with `XAVIER`,
  `ABXXCD`, `MAX` and `X-RAY` named directly;
- the reservation test floods with tokens that merely contain X and asserts a
  real mask keeps its run while none of the flood carries an X through;
- the exhaustion guard moves from `?XX` to single letters. This matters: with
  X positions now free, `?XX` has 20**3 replacements and can never run out,
  so leaving that shape would have turned a live guard into a test that
  cannot fail.

Mutation-checked: six assertions fail against the pre-fix module, reproducing
`XAVIER -> XZZZZZ` and `ABXXCD -> ZZXXZZ` exactly.

**Known gap, stated rather than glossed.** `sbi-bbox-capture.xml` still holds
one `XX111` token generated under the old rule; the corrected rule would
fabricate it. It is a staleness gap, not a leak — `XX` runs appear twenty
times across the real statements as the *bank's* redaction marks, not
customer letters, and the digits beside them were already fabricated. Every
distinct long digit run in both fixtures was cross-checked against four real
statements spanning three banks: zero matches. Regenerating the fixture needs
the source PDF and is not done here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): honour both mask shapes the parsers recognise

Classifying per token rather than per character was right, but it took the
wrong threshold. `bank_statement_import` recognises **two** account-mask
shapes: `[Xx]{4,}\d*` when deciding whether a standalone narration field is an
account, and `[Xx]+\d+` inside a UPI/IMPS reference, where a run as short as
`XX1234` is the bank's masking rather than a customer's letters. Requiring four
Xs everywhere fabricated `XX1234` into `ZZ1111`, destroying a shape the fixture
exists to preserve.

`_is_mask` is now the union, and still a whole-token test, which is what keeps
`XAVIER`, `ABXXCD` and `MAX` out — an X is structure only when the token is
nothing but a mask, and the short form additionally requires the digits that
make it an account reference rather than an initial. Twelve shapes checked; all
twelve land correctly.

This also settles the staleness gap named in the previous commit: the `XX111`
already in `sbi-bbox-capture.xml` is a mask under the parser's own rule, so it
was never stale.

**The end-to-end check was contradicting the unit cases.** `identifying_tokens`
drops tokens shorter than `IDENTIFYING_LENGTH`, and the survivor set subtracted
every pure-X token as deliberate — so a surviving `X`, `XX` or `XXX`, which the
unit cases define as a leak, was invisible to the artifact check and excused if
seen. The length exclusion exists because a one-digit token has nine possible
replacements and reserving them all starves the allocator; that reasoning is
about digits, not about X. Short tokens containing an X are now included, and
only tokens the parsers would actually call a mask are subtracted.

**The first version of this fix had no test.** Reverting `_is_mask` to the
four-X rule left the whole suite green — the new branch was asserted nowhere,
which is the failure this repository keeps writing down and I reproduced while
fixing someone else's instance of it. Three rows now cover the short form, and
the assertion itself was wrong on first writing: it checked for a fixed-length
run of leading Xs, which is false for `X99`. It now requires every X position to
survive verbatim and every digit position to be fabricated.

Mutation-checked in both directions: green with the union predicate, three
named failures without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): the short mask is an IMPS convention, not a global one

Scoping the mask decision per token was right; scoping it to the whole file was
not. `bank_statement_import` recognises `[Xx]+\d+` **only** inside an `IMPS/`
component, behind an alphabetic prefix and hyphens. `_is_mask` applied it
everywhere, so `X99` in any field at all was classified as masking and
`_fake_token` returned `X11` — carrying a customer's `X` into a public fixture.

That is the defect this function was written to fix, reintroduced one revision
later by widening the rule past the parser it mirrors. The first version decided
per character and leaked; the second decided per token but ignored context and
leaked again, in a narrower place.

The asymmetry worth stating, because it decides every future version of this
question: **a sanitiser may be narrower than the parser — the cost is a
fabricated mask shape — but never wider, because the cost there is a customer
character preserved verbatim.** Given a doubt about scope, take the narrower
reading.

`_scrub_plain` already holds the surrounding field, so the context the token
cannot carry is available one frame up and is passed down. The memo is keyed on
`(token, in_imps)` so the same token cannot be cached under the wrong scope.

Mutation-checked in **both** directions, which the previous version's tests
could not do: making the short form global again fails the three
outside-an-IMPS-field cases, and removing it entirely fails the three
inside-an-IMPS-field cases. A rule with a scope needs a test on each side of
the boundary, or it only ever proves one half of itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(tally): a fold belongs at lookup, never in the key — and the race is detected, not prevented

Three findings, all P1, and two of them are the same mistake at two phases.

**Folding a name to build a KEY destroys a row.** A catalogue holding both
`Alpha-Beta` and `Alpha Beta` holds two masters Tally keeps apart. Routing the
key through a fold collapses them into one mirror row before any ambiguity
handling can run — the second row is not ambiguous, it is *gone*, and nothing
downstream can recover a distinction destroyed on the way in. The same happens
to a case-differing pair under the directional predicate.

Both Phase 2 and Phase 4 now say it: **store rows under their exact codepoints;
fold only when resolving a request against those rows.** A fold reaching more
than one row is then an ambiguity to surface, which is only possible because
both rows still exist. This is the read-side twin of the binder/detector rule
already in this document — where a fold sits decides what it can destroy.

**The pre-dispatch guard cannot close its own window, and saying so is the
fix.** Another Tally client or an operator can create a colliding master between
the pre-read and the dispatch; §3.6's overwrite then happens anyway. Bridge's
single-writer actor owns Bridge's writes, not Tally's, and no amount of reading
earlier removes that.

What closes it is that the gateway reports the collision where it cannot hide:
a `Create` that finds a match returns **`CREATED=0, ALTERED=1`**. A create
dispatched from step 3a must therefore assert `CREATED=1`, and `ALTERED=1` is
not a success with a different label — it means a master that existed at
dispatch time has been overwritten with this payload. Alarm, halt, restore from
the pre-image; never a promotion to CONFIRMED.

Stated as detection rather than prevention on purpose. The guard narrows the
window to the dispatch itself; the counter assertion is what makes losing that
race loud instead of silent. A guard that claimed to eliminate the race would be
the more dangerous document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): a short run of X is data, and decode the capture as UTF-8

Two of the three findings left open when #303 was merged too early. The
third is recorded with its reproduction rather than rushed.

**A bare `X` or `XX` was copied into the fixture verbatim (P1).** The
all-`X` branch treated any token of nothing but `X` as a masking convention
and returned it unchanged — which also bypassed `reserve_source_tokens`, the
one check that exists to stop source text reaching a fixture.
`bank_statement_import` requires `[Xx]{4,}\d*` before it will call something
a masked account, so below four the run is a customer value: an initial, for
instance. Now gated on that same threshold, named `MASK_MIN_XS` beside the
alphabets so the two files cannot drift apart. `XXXXXXXX1234` still keeps
its run and fabricates the digits, which is the case the run exists for.

**The capture was decoded with the host locale (P2).** `pdftotext` emits
UTF-8; `read_text()` without an encoding uses the locale, so on a Windows
Python whose locale is not UTF-8 a raw `Café` becomes mojibake with extra
code points and Indic bytes raise `UnicodeDecodeError` before sanitisation
runs at all. Neither existing check reaches that boundary — CI is
ubuntu-only, and the Unicode cases call `_scrub_plain` with strings that are
already decoded. Pinned to UTF-8 on the read; the write already was.

**Not fixed: the cross-token key collision (P2), recorded instead.**
`_taken` keeps fabricated *tokens* distinct, but the reader concatenates
tokens and strips whitespace, so:

    source 'ACD'  -> 'ZZZ'        key 'ZZZ'
    source 'A CC' -> 'Z' + 'ZZ'   key 'ZZZ'    one mapping row

It is systematic, not rare — the counter is per shape, so the first token of
every shape starts at the alphabet's first letter. Fixing it properly means
the fabricated set must be uniquely decodable after whitespace removal,
which is a change to the fabricator rather than a guard bolted onto it. The
consequence is a fixture that could merge two parties and so fail to catch a
mapping-identity regression for that pair — worth fixing, not worth rushing
into a data-safety tool. The reachable case is in the test file with its
reproduction so it cannot quietly get worse.

Reverting the mask threshold makes the suite red on the first assertion.
Fixture bytes unchanged; neither file is pinned, so no reseal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): classify the masking shape per token, not per character

`MASK_MIN_XS` decided "is this X part of the masking convention?" one
character at a time. A token mixing X with anything else therefore made the
free-position list nonempty, skipped the all-X branch entirely, and carried
its own X straight through: `XAVIER` sanitised to `XZZZZZ`, `ABXXCD` to
`ZZXXZZ`. Those X characters are letters a customer typed, and they reached a
fixture built for a public repository — the precise leak the branch below
them was added to stop, through a narrower door.

The classification is now made once, for the whole token, against the pattern
the parsers actually use: `bank_statement_import` calls something a masked
account only when it matches `[Xx]{4,}\d*`. Inside that shape an X is
structure and survives; everywhere else an X is data and is fabricated like
any other letter.

This also fixes a second defect in the same expression. The old test was
`character != "X"`, uppercase only, so a lowercase mask was not recognised at
all and `xxxx5678` was destroyed to `zzzz1111` — losing the shape the fixture
exists to preserve. The pattern is `[Xx]` because the parser's is.

Three tests asserted the old per-character behaviour, one of them requiring
that the trailing X be preserved. They encoded the defect, so they are
rewritten rather than patched:

- the trailing-X assertion now asserts the opposite, with `XAVIER`,
  `ABXXCD`, `MAX` and `X-RAY` named directly;
- the reservation test floods with tokens that merely contain X and asserts a
  real mask keeps its run while none of the flood carries an X through;
- the exhaustion guard moves from `?XX` to single letters. This matters: with
  X positions now free, `?XX` has 20**3 replacements and can never run out,
  so leaving that shape would have turned a live guard into a test that
  cannot fail.

Mutation-checked: six assertions fail against the pre-fix module, reproducing
`XAVIER -> XZZZZZ` and `ABXXCD -> ZZXXZZ` exactly.

**Known gap, stated rather than glossed.** `sbi-bbox-capture.xml` still holds
one `XX111` token generated under the old rule; the corrected rule would
fabricate it. It is a staleness gap, not a leak — `XX` runs appear twenty
times across the real statements as the *bank's* redaction marks, not
customer letters, and the digits beside them were already fabricated. Every
distinct long digit run in both fixtures was cross-checked against four real
statements spanning three banks: zero matches. Regenerating the fixture needs
the source PDF and is not done here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scripts): honour both mask shapes the parsers recognise

Classifying per token rather than per character was right, but it took the
wrong threshold. `bank_statement_import` recognises **two** account-mask
shapes: `[Xx]{4,}\d*` when deciding whether a standalone narration field is an
account, and `[Xx]+\d+` inside a UPI/IMPS reference, where a run as short as
`XX1234` is the bank's masking rather than a customer's letters. Requiring four
Xs everywhere fabricated `XX1234` into `ZZ1111`, destroying a shape the fixture
exists to preserve.

`_is_mask` is now the union, and still a whole-token test, which is what keeps
`XAVIER`, `ABXXCD` and `MAX` out — an X is structure only when the token is
nothing but a mask, and the short form additionally requires the digits that
make it an account reference rather than an initial. Twelve shapes checked; all
twelve land correctly.

This also settles the staleness gap named in the previous commit: the `XX111`
already in `sbi-bbox-capture.xml` is a mask under the parser's own rule, so it
was never stale.

**The end-to-end check was contradicting the unit cases.** `identifying_tokens`
drops tokens shorter than `IDENTIFYING_LENGTH`, and the survivor set subtracted
every pure-X token as deliberate — so a surviving `X`, `XX` or `XXX`, which the
unit cases define as a leak, was invisible to the artifact check and excused if
seen. The length exclusion exists because a one-digit token has nine possible
replacements and reserving them all starves the allocator; that reasoning is
about digits, not about X. Short tokens containing an X are now included, and
only tokens the parsers would actually call a mask are subtracted.

**The first version of this fix had no test.** Reverting `_is_mask` to the
four-X rule left the whole suite green — the new branch was asserted nowhere,
which is the failu…
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