Bind source entities to masters deterministically in the shared crate - #288
Conversation
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>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
…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
There was a problem hiding this comment.
💡 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".
Live evidence against the synthetic books — and it does not support this PR as it standsReview thread on What was runAll 16 loaded synthetic companies on the local licensed TallyPrime 7.1 (Silver, What held
What did not hold
The narrowing does not narrow, and roughly a third of the time the right answer is not in the list. Cause, isolated: 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 forOf 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 threadsRead against the tree, not taken at face value. The correctness findings are accepted:
Plan
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>
There was a problem hiding this comment.
💡 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".
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>
There was a problem hiding this comment.
💡 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".
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>
There was a problem hiding this comment.
💡 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".
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>
There was a problem hiding this comment.
💡 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".
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>
There was a problem hiding this comment.
💡 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".
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>
There was a problem hiding this comment.
💡 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".
…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>
There was a problem hiding this comment.
💡 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".
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>
There was a problem hiding this comment.
💡 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".
…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>
#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
There was a problem hiding this comment.
💡 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".
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>
There was a problem hiding this comment.
💡 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".
… 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>
There was a problem hiding this comment.
💡 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".
…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>
There was a problem hiding this comment.
💡 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".
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>
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>
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>
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>
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>
…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>
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>
* 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…
* 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…
…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…
* 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 …
* 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…
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(MCPvalidate_masters) compared a folded key by equality or prefix and, for a near-miss, emittedexact_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
bridge-tally-core(the portable contract layer that already holds reconciliation);party_namefor egress redaction; Prepare source proposals with observed existing ledgers #276's catalogue capture, itsbind_selected/matchesadmission path and itsSourceDraftCatalogTargets; the existingvalidate_masterstool and its call sites; the existing candidate-count and byte bounds.agent_import.rs::master_matchand its privatemaster_key(−56 production lines there), andunicode-normalizationmoves out of the root crate's dependency list down intobridge-tally-core— the binary's dependency set is unchanged, and no dependency is added to the tree.validate_mastersreturns to prefix matching and the desktop returns the full catalogue with nobindingsfield.unresolved_identitywrapped 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-coreholds no GUID and grants no authority.The three rules
Ambiguous, never a bind. An identifier and an exact name pointing at different masters is reported rather than settled.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
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.bindthen has exactly one failure mode left.FallbackBinding::assigncan 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()reportsrequested/bound/ambiguous/unmatched/unbound, withrequested == bound + unboundasserted by test.Behaviour changes on the MCP surface
match_stategainsnormalizedandidentifieralongsideexact,near_miss,missing.exact_live_spellingnow appears only on a bound row.build_import_xmland the approved-post recheck still admitexactonly. 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 insource_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-25683d31bc9114410015c44a5366bdd7118b1e0145b9a2c517581e8c35e4e839a88. Both artifacts were regenerated with the supported tooling (rehash-surface→seal-surface→repoint-matrix) rather than hand-edited: exactly eight pin digests move, none added or removed.check-matrix-markdownreports current.tools/Cargo.lockgains exactly the ten lines forunicode-normalization;cargo check --lockedintoolsconfirms 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 a55500000xxplaceholder 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::StockItemis covered by contract and by test, but no stock-item catalogue read exists yet, so both shipped consumers passLedger.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.mdline 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
safe_reason_code./Users/paths, and known client tokens). The only long digit runs are the documented55500000xxplaceholder block and one fabricated XML date; every other match was a content hash.🤖 Generated with Claude Code
Where the desktop fields are consumed
SourceDraftCatalogTargets.bindingsandbindings_stateare returned by this PR and rendered by #290, which is stacked on this branch andMERGEABLE. 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_bindingsis covered by backend tests here —a_capture_narrows_each_source_entry_without_deciding_a_near_missanda_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.rswhile 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.