Rectify unqualified folded ledger binding - #331
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>
…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
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>
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>
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>
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>
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>
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>
…40' into claude/source-draft-narrow-targets # Conflicts: # docs/tally/compatibility/compatibility-matrix.json # docs/tally/compatibility/compatibility-surface.json
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>
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>
…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>
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>
…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>
…40' into claude/source-draft-narrow-targets # Conflicts: # docs/tally/compatibility/compatibility-matrix.json # docs/tally/compatibility/compatibility-surface.json
…k 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>
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>
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>
…40' into claude/source-draft-narrow-targets # Conflicts: # docs/tally/compatibility/compatibility-matrix.json # docs/tally/compatibility/compatibility-surface.json
`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>
…40' into claude/source-draft-narrow-targets # Conflicts: # docs/tally/compatibility/compatibility-matrix.json # docs/tally/compatibility/compatibility-surface.json
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>
…40' into claude/source-draft-narrow-targets # Conflicts: # docs/tally/compatibility/compatibility-matrix.json # docs/tally/compatibility/compatibility-surface.json
…rzakhani-94af40 # Conflicts: # docs/tally/compatibility/compatibility-matrix.json # docs/tally/compatibility/compatibility-surface.json
…40' into claude/source-draft-narrow-targets # Conflicts: # docs/tally/compatibility/compatibility-matrix.json # docs/tally/compatibility/compatibility-surface.json
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>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ff9d4664ff
ℹ️ 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".
…odex/rectify-issue329-20260912 # 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: c023687181
ℹ️ 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".
master moved when #320 landed, which stales the pinned digests on this branch. The only conflicts were the two compatibility manifests; the pin sets were identical on both sides (212 entries, none added or removed), so master's sealed manifest was taken and the digests recomputed from the merged tree rather than hand-edited. Reseal ran in the documented order: rehash-surface reported 4 changed entries, matching exactly the four pinned files this branch modifies (TALLY_PROTOCOL_REFERENCE.md, master_binding.rs, agent_import.rs, source_draft/catalog.rs). A confirming rehash after seal-surface and repoint-matrix reports 0 changed entries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
# Conflicts: # docs/tally/compatibility/compatibility-matrix.json # docs/tally/compatibility/compatibility-surface.json
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
master narrowed the same three paragraphs of TALLY_PROTOCOL_REFERENCE.md via #331 that this branch narrowed independently. Both say the measured slash case is directional and licenses no canonical form; the wordings differ, so git could not merge them. Resolution takes master's text for all three, because that wording shipped with the code change that removed BindingBasis::NormalizedName and because it withdraws the earlier claim as binding authority rather than erasing it from the probe history. This branch's operational RULE blockquote is kept after master's scope paragraph: master states the limit abstractly (does not generalize across product, tier, object class, direction or caller) while the blockquote names the concrete failure a reader must avoid (a fold treating punctuation as a class merges masters the gateway keeps apart). The branch's other three documents do not overlap with master and are carried unchanged. Compatibility surface resealed: rehash reported one changed entry, the protocol reference, and a confirming pass reports zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The code conflicts this branch was expected to carry (agent_catalog.rs, agent_import.rs, agent_import_tests.rs) auto-merged once #314 and #331 were on master; only documentation and the compatibility manifests conflicted. This branch carried a copy of #314's documentation corrections while #314 was under review, so both sides had independently reworded the same paragraphs. Resolution took whichever text states the post-#331 reality explicitly: - TALLY_PROTOCOL_REFERENCE.md keeps master's wording. Its "Superseded interpretation" block withdraws the earlier claim as binding authority rather than erasing it, and already states the candidate-only rule. - PROMPT_PLAYBOOK.md, IMPLEMENTATION_GUIDE.md and IMPROVEMENT_PLAN_2026H2.md keep this branch's wording, which names the binder directly: folded names remain candidates for operator selection and exact revalidation. Master's IMPLEMENTATION_GUIDE line implied a recorded directional comparison could still authorize automatic binding, which is no longer true after #331 removed BindingBasis::NormalizedName. Checked that taking this branch's PROMPT_PLAYBOOK did not drop #314's merged content: the staged file differs from master by 12 added and 5 removed lines, and those 5 are exactly the two reworded hunks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflicts were the two compatibility manifests, TALLY_PROTOCOL_REFERENCE.md, and the bank-statement sanitiser plus its test, which #316 also touched. - sanitise-bbox-capture.py takes this branch's side: it is a strict superset, carrying the same encoding="utf-8" read that master added plus this branch's keep-list materialisation and _load_parser call. - sanitise-bbox-capture.test.py takes this branch's side. _load_parser does not exist on master, so master's block is the pre-#332 shape; this branch's version adds the EvidenceRefusal handling that matches its own code, plus boundary controls for cropped rows, wrong-profile parsing and row-count mismatch. - TALLY_PROTOCOL_REFERENCE.md takes master's wording, consistent with how the same three paragraphs were resolved when #314 and #316 landed. Verified rather than assumed: scripts/sanitise-bbox-capture.test.py runs green on the merged tree ("all sanitiser contracts hold"). Compatibility surface resealed; a confirming rehash reports zero changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a_party_difference_echoes_the_source_spelling_not_its_catalog_binding proposed the party as "alpha traders" against a catalogue holding "Alpha Traders". That bound through BindingBasis::NormalizedName, which #331 removed from master while this branch was held. Post-#331 the folded spelling binds to nothing, so PartyOutcome is not Bound, and book_presence declines to report a difference at all -- by design: "Only a *bound* party can disagree: an ambiguous one has no single name to" compare against. The test was asserting pre-#331 binding semantics, not a property of difference reporting. The proposal now uses the catalogue's exact name. The test's actual subject is unchanged: a reported difference echoes the source spelling against the observed one, rather than echoing whatever the source bound to. This is the integration cost of a branch held open across a binding-contract change, and it was only visible in the test suite -- fmt, check and clippy all passed on the merged tree. 267 pass in bridge-tally-core. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#397 moved the window-completeness gate from `BookWindow::observed` to `Absent`'s production site, and #294 then synced to master. Both landed in this branch's base, conflicting in five files. Resolutions, none of which drop either side's work: - `book_presence.rs` (5 hunks). Kept this branch's `ObservedWindow` constructor, `ColumnEvidence` and `narration_evidence`; kept the base's `read` field and `read()` accessor; deleted the `WindowIncomplete` refusal the base removed. The withholding order in `decide` is preserved: `skipped_evidence`, `ManualNumberNotSupplied`, the party outcomes, then the window read, then `Absent`. - `agent_catalog.rs`. Both sides rewrote the `voucher_presence` description. Replayed this branch's two narration-marker sentences onto the base's rewritten contract text at anchors present verbatim in both, so neither description silently replaced the other. - `book_presence_tests.rs` (2 hunks). Took the base's renamed and inverted partial-window test through this branch's constructor, and set `codes.len()` by counting the `PresenceError` variants the test lists rather than by arithmetic on the two sides. - Both compatibility manifests. Took this branch's 217-pin set, which is a strict superset of the base's 216, then resealed with `scripts/reseal.sh` (36 hashes changed). Verified directly that no path is missing from either parent, since `--verify` passes over a dropped pin. Two fixes the merge itself required: `a_partial_window_withholds_absent_even_when_nothing_resembles_the_proposal` auto-merged cleanly against the pre-`ObservedWindow` positional constructor and would not have compiled. Ported. `a_party_difference_echoes_the_source_spelling_not_its_catalog_binding` passed on this branch and failed after the merge, deterministically. Cause is master, not either PR: "Rectify unqualified folded ledger binding" (#331) changed a folded name match from resolving a binding to only suggesting candidates, so the proposal's `alpha traders` no longer reached `PartyOutcome::Bound` and the party-difference block is guarded on `Bound`. An exact spelling would have made the test a duplicate of `a_party_difference_compares_the_observed_party_field_not_every_ledger` under a name that no longer described it, so the test moves to the basis that still decides without byte equality: an identifier embedded in the master name. Driven to failure by removing that identifier, to prove it is what carries the test. ADR 0017 named the type `RemoteIdEvidence`, which this branch renamed to `ColumnEvidence`, in three places. Corrected. The `RemoteIdEvidenceUnavailable` reason variant is unchanged. Verified under the pinned 1.96.0 toolchain: fmt clean; src-tauri workspace 1537 passed, 0 failed, 1 ignored (the manual live replay); doctests 2 passed; tools workspace 55 passed; clippy `-D warnings` clean on both workspaces with no warning or error lines; `reseal.sh --verify` current. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #329. Rectifies unqualified folded-name binding introduced by #288 and tracked by the P1 reviews on #314 and #316.
Functional summary
A generic catalogue has no product, release, licence, endpoint or operator-approval qualification. It therefore cannot authorize the historical gateway fold. Ledger and stock-item folded spellings now use the existing candidate path; exact names and decisive identifiers retain their behavior. Narrow-index candidates stay visible before the listing cap without outranking stronger identifier candidates or cloning/scanning entire holder vectors during sort.
Current
BindingBasishas onlyIdentifierandExactName; a normalized bound status cannot be constructed or deserialized. There is no core binding-report persistence reader. Historical captured JSON remains evidence, and persisted operator selections use their separate existing catalogue-binding representation with fresh exact revalidation. Import refusals explain operator candidate selection, updating the exact payload spelling, and revalidation before a new build.The ADR, protocol reference, test corpus and operator text preserve measured licensed Silver directional observations while withdrawing the generic symmetric/canonical inference. The source-draft fixture’s provenance explicitly distinguishes its captured catalogue from authored source XML.
Merge hold: review thread
hy1D8still requires a captured end-to-end source-draft flow. The retained DTO is derived from a captured catalogue plus authored source input; these tests cannot close that P1. Authored synthetic XML is legitimate source-draft input; the outstanding evidence is the actual native load/catalogue command and its returned DTO. Read-only native connection and guarded company selection succeeded after the SSH restart. The Mac is currently locked; the final native load/catalogue capture remains pending on this candidate. #290 has landed. This candidate normally integrates master06528eeb615b827896b9649ea476034143bbf7f9and targets master; its entire source tree is byte-identical to the accepted723a572c4895c025002aac0b3e2769536c9b8ff9tree. Hosted checks and review must qualify the new head.Test or reproduction command
Candidate
314ff0a8fa8a053103d2780d3065647034afd7d1:cargo test --locked --manifest-path src-tauri/Cargo.toml -p bridge-tally-core --lib: 149 passed on the final core runtime/test bytes atdec42831330987bc7330504668b93a74ba2986f7. Core and adapter Clippy passed with warnings denied. These full-suite results are retained in the agent execution transcript; a separate full log was not retained.one_master_matchparser/binder/renderer with one narrow-fold name and ten lexically earlier wide-only matches whose combined bytes exceed 8,192. The narrow candidate remains first and visible in the truncated output.validate_mastersbefore building; no file is written on refusal.Migration compatibility
Current report deserialization intentionally rejects the legacy normalized basis. No core binding-report storage migration or database change is introduced. Newly prepared folded names require operator selection and exact revalidation. Existing saved operator choices retain their independent freshness checks.
Rollback notes
Reverting would restore unqualified automatic folded binding. Keep candidate-only behavior in any replacement. The read-only native connection and company-selection probe is not final source-draft evidence. No financial mutation or import was performed.
Reuse/deletion: reuses the existing candidate matcher and deletes automatic folded-name authority. No unused scope registry, compatibility type, feature flag, dependency or alternative matcher. Without the repair, a downstream exact-spelling check can validate an incorrect target after a caller copies an unqualified bound name.
Security impact
Automatic target selection is narrowed; company/endpoint admission, fresh catalogue checks and exact import authorization remain enforced. No DSC, credential, transport or dispatch change. Local regression evidence does not replace the explicit end-to-end capture hold.