diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index 73a0dfc0..d9ddd59f 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -89,13 +89,13 @@ colliding pair surfaces in the unbound list where an operator can see it. Failing a whole read to report one collision would block all the work it was performed for. -`MasterClass` is `Ledger` or `StockItem`. Both classes failed in practice and -the identifier rules are identical for both, but **the name fold is not**: -§9.4d measured ledgers, and whether a stock item matches by the same rule was -never sent. So a folded stock-item name may *suggest* and may not resolve — -byte equality is unaffected, since it needs no fold. The class is carried both -so a report cannot be applied to the wrong catalog and because the evidence -behind the two differs. +`MasterClass` is `Ledger` or `StockItem`. The identifier rules are identical +for both. Gateway measurements remain useful for candidate ordering, but a +`MasterCatalog` has no product, release, tier, endpoint, or operator-approval +scope. Therefore every folded name suggests candidates only, for either class; +byte equality is unaffected because it needs no fold. The class is carried so +a report cannot be applied to the wrong catalog and because the evidence behind +the two still differs. ### 2. The identifier is the key; the name is a hint @@ -207,26 +207,27 @@ signals disagree, and the disagreement is reported (`IdentifierNameConflict`) rather than silently settled in the identifier's favour. -### 3. Name matching binds only on an exact or normalized-exact unique hit +### 3. Name matching binds only on byte equality -`Exact` is byte equality with the observed master name. `Normalized` is equality -under **Tally's own rule for when two master names are the same**, and only when -exactly one master shares it. +`Exact` is byte equality with the observed master name. A folded name is a +candidate even when exactly one observed master shares its key. -**There are two folds, and which one may answer is the whole of this section.** +**There are two folds, and neither answers in this generic catalog.** -The resolving fold implements exactly the equivalences -`TALLY_PROTOCOL_REFERENCE.md` §9.4d measured on **licensed TallyPrime 7.1** — -the SKU this writes to — by naming each spelling in a voucher and reading the -day book back to see which master it reached: +The narrower fold is a **historical candidate index**, not a current statement +of qualified gateway equivalence. It uses the transformations a previous +implementation treated as equivalent: case, boundary and repeated spaces, and +ASCII space, `-`, and `/`. That index may be broader than the qualified +measurements, so it can only order candidate suggestions. The supporting +observation was scoped to Silver; it measured a slash in the source reaching a +space in the master, but did not measure the reverse direction. It does not +establish a generic, symmetric separator rule. -- ASCII case folds; -- leading and trailing whitespace is ignored; -- an internal run of spaces collapses; -- **space, `-` and `/` are one separator**, in both directions. - -Everything else is exact on codepoints. The wide fold (`master_identity_key`) -carries more than that and may only offer candidates. +The wide fold (`master_identity_key`) carries more still and also only offers +candidates. `MasterCatalog::new(class, names)` carries no product, release, +tier, endpoint, or operator-approval scope that could authorize a caller to +select a folded match. A write gate cannot repair a wrong selection once a +caller has copied its returned name. **The two rules that matter are negative, and neither is guessable.** An **en dash** and an **underscore** were sent and *rejected*: they are not separators @@ -245,12 +246,12 @@ authority. That was corrected by narrowing to the three §9.4b had measured, which cost 420 of 995 mutation binds and withdrew `X - Y`, a common ledger convention. -Then the narrowing turned out to be over-strict, because §9.4b's scope is *Edit -Log 7.0 Educational* and this project writes to licensed 7.1. Measuring that SKU -directly (§9.4d) found the gateway wider: the reverse hyphen direction, leading -whitespace, collapsed runs and slash all match. The fold is symmetric again, one -key per side, and the asymmetric index the narrow version needed is gone. On the -mutation book **600 of 995** now bind, against 420 under the narrow fold. +Then a later historical interpretation treated the index as a wider licensed +7.1 rule and bound **600 of 995** mutation names, against 420 under the narrow +fold. That was a prior binding result, not a measured candidate count, and is +withdrawn as authority: its scope and directional support were overstated. The +index remains only to make the same possible masters visible to an operator; it +does not bind in the generic catalog. The lesson is not "measure more". It is that **the scope line of an inherited measurement is part of the measurement**: §9.4b was accurate and its scope was @@ -394,7 +395,7 @@ Per entity, exactly one of: | status | meaning | | --- | --- | -| `Bound { catalog_name, basis }` | one master, decided by `Identifier`, `ExactName`, or `NormalizedName` | +| `Bound { catalog_name, basis }` | one master, decided by `Identifier` or `ExactName` | | `Ambiguous { candidates, .. }` | more than one master is defensible, including every identifier conflict | | `Unmatched { candidates, .. }` | no rule produced a candidate | @@ -437,16 +438,16 @@ voucher, creates no master, and dispatches nothing. ## Consequences - `agent_import.rs::master_match` and its private `master_key` are deleted and - `validate_masters` is re-expressed over the crate. `match_state` gains - `normalized` and `identifier` alongside `exact`, `near_miss` and `missing`, - and `exact_live_spelling` now appears only on a bound row. A caller reading - that field on a near-miss was reading a guess. -- The old implementation classified *every* normalized-equal name as a - near-miss, so a request differing from the live ledger only in case, - whitespace, or dash style produced a candidate list instead of an answer. - Those now bind and report the live spelling. + `validate_masters` is re-expressed over the crate. `match_state` reports + `exact`, `identifier`, `near_miss`, or `missing`; folded spellings remain + `near_miss` candidates and `exact_live_spelling` appears only on a bound row. +- Current `BindingBasis` cannot construct or deserialize `NormalizedName`. + The core has no binding-report persistence reader; older captured report + JSON remains historical evidence, not a current binding input. Persisted + operator selections still use their existing separate catalogue-binding + representation and require a fresh exact catalogue check before use. - `build_import_xml` and the approved-post recheck still admit **`exact` only**. - The import file carries the name verbatim, so a normalized or identifier bind + The import file carries the name verbatim, so a folded candidate or identifier bind informs the operator without widening what may be written. This PR does not move the write gate. - The MCP result reports an unbound entity's `unresolved_identity` wrapped in diff --git a/docs/agent/README.md b/docs/agent/README.md index af41af45..f72abd18 100644 --- a/docs/agent/README.md +++ b/docs/agent/README.md @@ -208,10 +208,12 @@ licence mode, or manually imported file, and only an unnumbered single-voucher 2. Call `validate_masters` with every ledger name. **`build_import_xml` admits `exact` only**, so replace the payload name for every row that is not `exact`, and never invent one: - - `normalized` or `identifier` — the row is bound. Copy its - `exact_live_spelling` into the payload verbatim; the live name may differ - from yours in case, spacing, dash or quote style, and the import file - carries whatever you send byte for byte. + - `identifier` — the row is bound by a decisive identifier. Copy its + `exact_live_spelling` into the payload verbatim; the import file carries + whatever you send byte for byte. Folded names do not bind through this + generic catalogue, even when exactly one candidate is found. Historical + `normalized` records remain readable, but current validation does not + produce them; revalidate against the current catalogue before selection. - `near_miss` — the row is **not** bound and Bridge chose nothing. Where `listing` is `withheld`, `candidates` is empty: there is no listed name to pick. This includes `master_binding_no_discriminating_candidate` and diff --git a/docs/tally/TALLY_PROTOCOL_REFERENCE.md b/docs/tally/TALLY_PROTOCOL_REFERENCE.md index 56fed873..bde016da 100644 --- a/docs/tally/TALLY_PROTOCOL_REFERENCE.md +++ b/docs/tally/TALLY_PROTOCOL_REFERENCE.md @@ -1267,6 +1267,14 @@ assuming something nobody has measured. ### 9.4d Master-name matching on **licensed** TallyPrime 7.1 +**Superseded as generic binding authority, 2026-09-12.** The observations below remain +an exact record for their one licensed 7.1 instance, company, ledger class and import-time +operation. They do not authorize a scope-free `MasterCatalog` to bind a folded spelling: +that constructor carries none of the product, release, endpoint or approval information the +measurement requires. Generic binding therefore presents every folded result as a candidate +and requires operator selection plus exact revalidation; it must not treat these directional +observations as a symmetric, portable canonicalization rule. + **VERIFIED 2026-09-12**, and it widens §9.4b rather than confirming it. §9.4b is inherited from a 2026-07-30 measurement on **Edit Log 7.0 Educational** and marks licensed TallyPrime UNVERIFIED. This is that measurement re-run on the SKU this project actually writes to: **TallyPrime 7.1, @@ -1331,18 +1339,20 @@ more variants, same method, same readback and deletion: | ` mb-pilot/alpha (5550001001) ` | all five at once | **matched** | | ` mb probe ledger a ` against `MB-PROBE-LEDGER-A` | case + space-for-hyphen + surrounding + run | **matched** | -All eight posted against the intended master, confirmed by day-book readback. **So the folds -compose**, and a canonical form applying every measured transformation before comparing is -licensed by measurement rather than by extrapolation from the single-axis rows. +All eight posted against the intended master, confirmed by day-book readback. -**What this says.** On licensed 7.1, Tally treats **space, hyphen and slash** as interchangeable -separators, collapses internal whitespace runs, ignores leading and trailing whitespace, folds -**ASCII** case, and is otherwise **exact on codepoints**. +**Superseded interpretation, 2026-09-12.** The rows above remain the scoped Silver 7.1 +observations. They do **not** license a generic symmetric or canonical separator fold: the +measured slash direction is a slash in the supplied name reaching a space in the live master; +the reverse direction was not sent. A scope-free binder must therefore keep all folded spellings +candidate-only and require operator selection plus exact revalidation. The earlier statements +that `space`, `-`, and `/` are interchangeable, or that a canonical form is licensed, are +withdrawn as binding authority rather than erased from the probe history. -> **RULE: separators fold, and the set is `space`, `-`, `/` — nothing else.** An en dash and an -> underscore are ordinary characters to Tally and are **not** separators, so a fold that treats -> "punctuation" or "separators" as a class is wider than the gateway and will merge masters it -> keeps apart. +**What remains measured in this scope.** The listed forward slash-to-space case, the recorded +hyphen and whitespace cases, and the rejected en dash, underscore, abbreviation, suffix, and +NFD cases are observations of this one operation. They do not generalize across product, tier, +object class, direction, or caller. That is the trap §9.4b warned about, arriving from the other side: the danger was never only that a reader would fold too much, it was that "normalises separators" names no particular set. Two of diff --git a/docs/tally/TEST_CORPUS.md b/docs/tally/TEST_CORPUS.md index 66f63494..d08fd671 100644 --- a/docs/tally/TEST_CORPUS.md +++ b/docs/tally/TEST_CORPUS.md @@ -457,8 +457,9 @@ names**. | `Zeta Nowhere Traders` | `missing`, `listing: "none"` | an absence stated as an absence, not as an empty list | | `MB PARTY BETA` | `near_miss`, 4 candidates | a prefix family surfaced whole | -**`exact_live_spelling` appeared on bound rows only** — `exact`, `identifier`, `normalized` — -and on no refusal. The same ten names sent to the **previously installed** server, same company +**`exact_live_spelling` appeared on bound rows only** — `exact`, `identifier`, and the +historical `normalized` result — and on no refusal. `normalized` is superseded as a generic +binding state: a current scope-free catalog keeps that result candidate-only. The same ten names sent to the **previously installed** server, same company and instance minutes earlier, returned `exact_live_spelling` alongside `match_state: "near_miss"` for two of them. That field on a refusal is a guess wearing the shape of an answer, and it is what this change deletes; the two runs are the before and after on one real instance. @@ -471,9 +472,9 @@ the MCP consumer; the desktop consumer shares the crate but was not driven. **Superseded in part, 2026-09-12.** The separator row below was read as a defect and drove a narrowing of the resolving fold. `TALLY_PROTOCOL_REFERENCE.md` §9.4d then measured that same equivalence directly on **licensed** TallyPrime 7.1 and found Tally does accept it — along with -leading whitespace, collapsed runs and a slash — so the fold was widened back to what the gateway -actually does. Read the paragraph below as the history it is: the row's `normalized` result was -right, and the reasoning that called it wrong was working from §9.4b's Educational scope. +leading whitespace, collapsed runs and a slash — so the capture retained the result as a scoped observation. Read the paragraph below as history: +the row records what that one gateway accepted, but it does not license a generic catalog to +resolve a folded name without an operator's selected target and exact revalidation. **It found a defect, which is the reason to run these.** On the first run the third row *bound* `MB-PILOT-ALPHA-(5550001001)` to a master carrying spaces. That is the **reverse** of the diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index c5af682b..835c55ac 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "125f7f9f6eb890ea5bb98da0402454c77ac5b33bb17ff9af455a10f6d1d02a78", + "compatibility_surface_sha256": "f0e1a2c88e5905abe06daccf580611d011b11dc3f126fe799b74ea779f7fcd05", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 7efa41c3..c8da4d7b 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -35,7 +35,7 @@ }, { "path": "docs/tally/TALLY_PROTOCOL_REFERENCE.md", - "sha256": "724cf4ae4c34ae39858959eb3da8ca488b740db8c5234a651d0574228ae5bbcf" + "sha256": "2a115ee06c8f26caae7f8fb703e709599f3ca01dc87c6484bb0ec88b1ff870dd" }, { "path": "docs/tally/compatibility/README.md", @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "daa8640b0cd90799d108591f723481063794dfd6bc44d17b8b371601fb433284" + "sha256": "4caadad956d18f20012c413c24f391569cf92ede237fc5e83d67ede1fa62dc83" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -331,7 +331,7 @@ }, { "path": "src-tauri/src/agent_import.rs", - "sha256": "aaed843e02cb3410b9aca9ab5bae46ea15b0d7bc7866f37f5c45edfaa1b8f117" + "sha256": "709a76ae4daa0f2e2485f8c02d0126360705782a51e87d65ec001670acfe5c6e" }, { "path": "src-tauri/src/agent_ledgers.rs", @@ -583,7 +583,7 @@ }, { "path": "src-tauri/src/source_draft/catalog.rs", - "sha256": "9b6fce29bb15eb0f71e48bcd364244b1e16893e13f24050cead465642a640184" + "sha256": "e4fd8adb261120298c7f02f32fb05e14860f3bd433c472116b1e38c384068581" }, { "path": "src-tauri/src/source_draft/files.rs", @@ -850,5 +850,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "125f7f9f6eb890ea5bb98da0402454c77ac5b33bb17ff9af455a10f6d1d02a78" + "manifest_sha256": "f0e1a2c88e5905abe06daccf580611d011b11dc3f126fe799b74ea779f7fcd05" } \ No newline at end of file diff --git a/scripts/fixtures/source-draft-capture-bindings.json b/scripts/fixtures/source-draft-capture-bindings.json index 6c56c3c5..b2257a8b 100644 --- a/scripts/fixtures/source-draft-capture-bindings.json +++ b/scripts/fixtures/source-draft-capture-bindings.json @@ -12,15 +12,18 @@ "unbound_reason": null }, { - "bound_basis": "normalized_name", - "bound_target": "WR2 Sales", - "candidate_count": 0, + "bound_basis": null, + "bound_target": null, + "candidate_count": 2, "candidate_count_is_lower_bound": false, - "candidate_listing": "none", - "candidates": [], + "candidate_listing": "listed", + "candidates": [ + "WR2 Sales", + "WR2 XML Café Naïve Ledger 01A01A2F" + ], "entry_position": 2, "row_position": 1, - "unbound_reason": null + "unbound_reason": "master_binding_near_miss" }, { "bound_basis": null, @@ -44,6 +47,11 @@ "response_sha256": "f354993704f0feddc27a46d73b4ca10787b6028d9f385d8323994b18e5b34e0f", "state": "complete" }, + "provenance": { + "catalogue": "captured StandardLedgerCatalogV1 response", + "source_xml": "authored test input", + "binding_dto": "derived by the production parser and binder" + }, "source_sha256": "41c4de4324e0eb03c46b92aee7b0be0a0a1ff29feb63ffc3b8435cce559810aa", "targets": [ "Bridge Nested Debtor WR4", diff --git a/scripts/source-draft-screen.test.tsx b/scripts/source-draft-screen.test.tsx index 364fc7c2..ad174b8c 100644 --- a/scripts/source-draft-screen.test.tsx +++ b/scripts/source-draft-screen.test.tsx @@ -600,9 +600,9 @@ test("distinguishes the two other refusals that are not weak matches", async () test("the picker groups a real captured catalogue, not a shape the test invented", async () => { // Every other test here writes both the catalogue and its bindings, so they // show the component agrees with an assumed response. This one reads - // `scripts/fixtures/source-draft-capture-bindings.json`, which is the DTO the - // **producer** emits from a `StandardLedgerCatalogV1` response captured on - // licensed TallyPrime 7.1 — nine real ledger names, including Devanagari, an + // `scripts/fixtures/source-draft-capture-bindings.json`, a DTO derived by the + // producer from a `StandardLedgerCatalogV1` response captured on licensed + // TallyPrime 7.1 and authored source XML — nine real ledger names, including Devanagari, an // `&` name, and an NFD ledger beside NFC ones. // // The Rust test `the_binder_meets_a_real_catalogue_through_the_production_parse` diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs index 17a1eaff..dd899613 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -291,8 +291,6 @@ pub enum BindingBasis { Identifier, /// Byte equality with the observed master name. ExactName, - /// Equality under the comparison key, unique in the catalog. - NormalizedName, } /// The masters worth showing, and — in the variant itself — what an absence of @@ -427,9 +425,11 @@ pub struct Unresolved { /// acting on a binding re-reads and revalidates through the admission path /// that owns identity; nothing here is a lease on the book. /// - **Not that the name may be written as given.** Only `ExactName` is byte -/// equality. A `NormalizedName` or `Identifier` bind means the payload and -/// the live name *differ*, and Bridge's write gate admits `exact` only — use -/// `catalog_name`, not what was requested. +/// equality. Binding reports have no core persistence reader, so current +/// `BindingBasis` deliberately rejects historical folded wire values; current +/// folded names are candidates. An `Identifier` bind means +/// the payload and live name can differ, and Bridge's write gate admits +/// `exact` only — use `catalog_name`, not what was requested. /// - **Not that this is the right master in business terms.** It establishes /// that one deterministic rule selected one master uniquely. Whether that /// party is the one the document meant is a judgement the rules cannot make. @@ -676,9 +676,10 @@ impl FallbackBinding { pub struct SourceEntity { position: usize, name: String, - /// The wide fold. Suggests; never resolves. + /// The wide fold. Suggests candidates; never decides a binding. key: String, - /// The narrow fold. Resolves. + /// The observed gateway fold. In this unscoped catalog it also only + /// suggests candidates; authority requires an explicit scoped path. binding_key: String, identifiers: Vec, } @@ -1219,27 +1220,18 @@ fn bind_one( basis: BindingBasis::Identifier, } } else { - // The narrow index, not the wide one: only a transformation Tally was - // measured performing may settle which master was meant. Everything the - // wide fold reaches and this does not falls through to `collect_candidates` - // below, where it is offered as `NormalizedEqual` for a human to confirm. + // This catalog carries no scope-qualified authority for a fold. A + // gateway observation can inform a candidate search, but cannot make a + // name authoritative for a different observed product/tier/scope. + // Exact names and identifiers above remain decisive; every folded name + // reaches the existing candidate path for a human to confirm. match catalog .by_binding_key .get(&entity.binding_key) .map(Vec::as_slice) { - // §9.4d measured **ledgers**. Whether stock items match by the - // same rule is not merely unmeasured, it was never sent — so a - // folded stock-item name may suggest and may not resolve. Byte - // equality is unaffected: it needs no fold and is checked above. - Some([index]) if catalog.class == MasterClass::Ledger => BindingStatus::Bound { - catalog_name: catalog.entries[*index].name.clone(), - basis: BindingBasis::NormalizedName, - }, - // A single folded match that the class does not license is not an - // ambiguity — nothing shares its key. `NameAmbiguous` would tell a - // consumer that several masters collided when exactly one did not - // qualify, which is a different fact with a different remedy. + // A single candidate is not an ambiguity — exactly one master was + // found, but the catalog cannot prove the fold names it. Some([_]) => unresolved_status( catalog, entity, @@ -1355,7 +1347,7 @@ fn unresolved_status( // equality was observed, which left the operator reading the two facts // that disagreed without being told one of them was exact. candidates.retain(|(candidate, _)| *candidate != index); - candidates.push((index, CandidateRule::ExactName)); + candidates.insert(0, (index, CandidateRule::ExactName)); } unresolved_from( catalog, @@ -1380,8 +1372,13 @@ fn unresolved_from( count_evidence: CountEvidence<'_>, budget: &mut usize, ) -> BindingStatus { - let mut ordered = candidates; - ordered.sort_by(|left, right| candidate_order(catalog, left, right)); + // `collect_candidates` has already applied the bounded presentation order, + // including the narrower binding-key holders before wider-only candidates. + // Keep that order through the byte budget: sorting again by name here can + // spend the budget on a long wide candidate and hide the narrow evidence. + // Exact-name evidence is inserted at the front by `unresolved_status`, so + // the rule precedence remains explicit without discarding same-rule order. + let ordered = candidates; let (found, count_is_lower_bound) = candidate_count( masters_found, &ordered, @@ -1640,12 +1637,12 @@ fn collect_candidates( // token sets and memo keys across the whole module on the strength of it, // and still leave the candidate list assembled from a key that is not the // one the ambiguity was found in. - for index in catalog + let binding_matches = catalog .by_binding_key .get(&entity.binding_key) - .into_iter() - .flatten() - { + .map(Vec::as_slice) + .unwrap_or(&[]); + for index in binding_matches { offer(*index, CandidateRule::NormalizedEqual); } if withheld.is_empty() { @@ -1718,29 +1715,32 @@ fn collect_candidates( // // `found` is computed above from the full union, so the count an operator // sees is unaffected by the cap; only the listing is. - listed.sort_by(|left, right| candidate_order(catalog, left, right)); + // The narrower historical index was the reason this near-miss was reached. + // Preserve its candidates before the bounded listing drops wider-only ones; + // this is visibility, never authority or a similarity score. + listed.sort_by(|left, right| { + // Candidate-rule precedence is unchanged: an identifier still leads a + // folded suggestion. Within the same rule, a binary-searchable narrow + // holder gets the bounded slot before a wider-only holder. + left.1 + .rank() + .cmp(&right.1.rank()) + .then_with(|| { + binding_matches + .binary_search(&right.0) + .is_ok() + .cmp(&binding_matches.binary_search(&left.0).is_ok()) + }) + .then_with(|| { + catalog.entries[left.0] + .name + .cmp(&catalog.entries[right.0].name) + }) + }); listed.truncate(MAX_CANDIDATES_PER_ENTITY); (listed, found) } -/// How candidates are ordered wherever they are ordered: by the rule that -/// reached them, then by the master's name. -/// -/// Defined once because `collect_candidates` truncates in this order and -/// `unresolved_from` sorts in it, and a disagreement between the two would -/// silently drop a candidate that should have been listed. -fn candidate_order( - catalog: &MasterCatalog, - left: &(usize, CandidateRule), - right: &(usize, CandidateRule), -) -> std::cmp::Ordering { - left.1.rank().cmp(&right.1.rank()).then_with(|| { - catalog.entries[left.0] - .name - .cmp(&catalog.entries[right.0].name) - }) -} - /// A name is retained **verbatim**, on both sides. /// /// An observed master name is written back to Tally byte for byte by a caller @@ -1749,8 +1749,8 @@ fn candidate_order( /// against, so trimming it would let `Bank ` claim an exact match on `Bank` /// while the import file still carries the trailing space. The comparison key /// collapses surrounding whitespace anyway, so the two still meet as a -/// normalized match — which is a bind the write gate does not admit, and that -/// is the correct, loud outcome. +/// normalized candidate. It can help an operator find the observed spelling, +/// but no generic catalog is authorized to select it. fn validated_name(value: &str) -> Result { validate_name_bounds(value)?; Ok(value.to_string()) @@ -1803,12 +1803,12 @@ pub(crate) fn comparison_key(value: &str) -> String { /// The **wide** fold: which masters are worth showing a human. /// /// This is deliberately looser than anything measured, and it may never decide -/// a binding. `verified_fold` does that. The separation is the whole design: -/// §9.4b verified three transformations and marks the rest UNVERIFIED, and its -/// own remedy is that a looser fold may *suggest* while only the measured ones -/// resolve. So the reverse hyphen direction, collapsed whitespace runs, leading -/// whitespace and the Unicode dash variants all live here, where the worst they -/// can do is put the right master in front of an operator. +/// a binding. The observed gateway fold is narrower, but this catalog has no +/// product, release, tier, endpoint, or operator-approval scope to treat that +/// observation as selection authority. Both folds therefore only suggest +/// candidates here. So the reverse hyphen direction, collapsed whitespace +/// runs, leading whitespace and the Unicode dash variants all live here, where +/// the worst they can do is put the right master in front of an operator. /// /// An earlier version of this module let this fold bind. It read naturally and /// was wrong: `X - Y` is a common ledger convention — six of seventeen @@ -1832,20 +1832,15 @@ fn master_identity_key(value: &str) -> String { .join(" ") } -/// The fold that may **resolve** a name to a master: exactly the equivalences -/// `TALLY_PROTOCOL_REFERENCE.md` §9.4d measured on the SKU this writes to. -/// -/// §9.4b measured Edit Log 7.0 Educational and marked most of this UNVERIFIED, -/// so an earlier version of this module resolved on three transformations only -/// and offered the rest as candidates. §9.4d re-ran that measurement on -/// **licensed TallyPrime 7.1**, read the day book back to see which master each -/// name actually reached, and found the gateway wider than the Educational -/// scope allowed anyone to claim: +/// A historical candidate index, retained for deterministic ordering. /// -/// - ASCII case folds; -/// - leading and trailing whitespace is ignored; -/// - an internal run of spaces collapses; -/// - **space, `-` and `/` are one separator**, in both directions. +/// It may be broader than qualified gateway measurements and cannot resolve a +/// name through `MasterCatalog`, whose constructor receives neither a product, +/// release, tier, endpoint, nor explicit operator approval. The historical +/// record was scoped to Silver and measured a slash in the source reaching a +/// space in the master; it did not establish the reverse direction or a +/// generic symmetric separator rule. This key can therefore only suggest a +/// candidate to an operator. /// /// Everything else is exact on codepoints. So the two rules that matter are /// both negative, and neither is guessable from appearance: @@ -1861,8 +1856,8 @@ fn master_identity_key(value: &str) -> String { /// onto a master the gateway keeps apart. It reads like decoding rather than /// folding, which is how it survived two audits of this function. /// -/// Both hyphen directions are measured now, so this is symmetric and one key -/// per side is enough — the asymmetric index an earlier version needed is gone. +/// The implementation remains symmetric solely for candidate discovery. That +/// convenience does not claim symmetric gateway behavior. fn verified_fold(value: &str) -> String { value .chars() @@ -1937,12 +1932,12 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro } else if token.chars().any(char::is_alphanumeric) { previous_was_mask = false; } - // Only the separators §9.4d measured may be discarded. Filtering to - // alphanumerics dropped **every** ASCII punctuation mark, so + // This historical parser discards only `-` and `/`, rather than every + // ASCII punctuation mark. It is not a claim of gateway equivalence. + // Filtering to alphanumerics dropped **every** ASCII punctuation mark, so // `AB_123456` and `AB-123456` canonicalized alike and one identifier - // bound the other's master — while §9.4d had sent an underscore and - // watched Tally *reject* it. The evidence for this fold is one - // measurement about hyphens and slashes; everything else stays content. + // bound the other's master; underscore remains content rather than + // joining the historical candidate normalization. let canonical = token .chars() .filter(|character| !matches!(character, '-' | '/')) @@ -1980,8 +1975,8 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro && digits >= MIN_CODE_IDENTIFIER_DIGITS && letters >= 2 && !foreign_content - // A separator works in both directions, so the date guard has to - // run on both spellings. Removing `-` can *reveal* a date — + // This parser removes separators in either spelling, so the date + // guard has to run on both spellings. Removing `-` can *reveal* a date — // `2025-09-11` becomes `20250911` — and it can just as easily // *hide* two: `DATED20250911-20250912` fuses into one sixteen-digit // run that reads as no date at all, and `is_period` does not see it diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs index 4aebf535..eb9ae0ae 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -90,36 +90,25 @@ fn the_measured_transformations_compose() { // does on every comparison. Two reviewers raised that independently, so // §9.4d measured it rather than arguing it: eight composed variants, all // matched, all confirmed by day-book readback against the intended master. - let catalog = ledgers(&[ - "MB PILOT ALPHA (5550001001)", - "MB-PROBE-LEDGER-A", - "Beta Supply", - ]); + let catalog = ledgers(&["MB PILOT ALPHA", "MB-PROBE-LEDGER-A", "Beta Supply"]); for (supplied, expected) in [ - ( - " mb pilot alpha (5550001001) ", - "MB PILOT ALPHA (5550001001)", - ), - ("mb-pilot-alpha-(5550001001)", "MB PILOT ALPHA (5550001001)"), - ( - " mb-pilot-alpha-(5550001001) ", - "MB PILOT ALPHA (5550001001)", - ), - ( - "MB/PILOT ALPHA (5550001001)", - "MB PILOT ALPHA (5550001001)", - ), - ("mb-pilot alpha/(5550001001)", "MB PILOT ALPHA (5550001001)"), - ( - " mb-pilot/alpha (5550001001) ", - "MB PILOT ALPHA (5550001001)", - ), + (" mb pilot alpha ", "MB PILOT ALPHA"), + ("mb-pilot-alpha", "MB PILOT ALPHA"), + (" mb-pilot-alpha ", "MB PILOT ALPHA"), + ("MB/PILOT ALPHA", "MB PILOT ALPHA"), + ("mb-pilot alpha", "MB PILOT ALPHA"), + (" mb-pilot/alpha ", "MB PILOT ALPHA"), (" mb probe ledger a ", "MB-PROBE-LEDGER-A"), ] { + let binding = bind_one_name(&catalog, supplied); assert_eq!( - bind_one_name(&catalog, supplied).bound_name(), - Some(expected), - "{supplied:?} did not compose to {expected:?}" + binding.bound_name(), + None, + "{supplied:?} selected {expected:?}" + ); + assert!( + candidate_names(&binding).contains(&expected), + "{supplied:?} did not offer {expected:?}" ); } @@ -134,9 +123,9 @@ fn the_measured_transformations_compose() { } #[test] -fn surrounding_and_repeated_whitespace_is_folded_on_both_sides() { - // §9.4d: leading whitespace, one trailing space and a collapsed internal - // run all matched on licensed 7.1, in both directions. +fn surrounding_and_repeated_whitespace_is_offered_on_both_sides() { + // The historical index can find these spellings, but the unscoped catalog + // cannot select them. for (master, source) in [ ("Alpha Traders", "Alpha Traders "), ("Alpha Traders ", "Alpha Traders"), @@ -146,18 +135,17 @@ fn surrounding_and_repeated_whitespace_is_folded_on_both_sides() { ("Alpha Traders", "Alpha Traders"), ] { let catalog = ledgers(&[master, "Beta Supply"]); - assert_eq!( - bind_one_name(&catalog, source).bound_name(), - Some(master), - "{source:?} did not reach {master:?}" - ); + let binding = bind_one_name(&catalog, source); + assert_eq!(binding.bound_name(), None, "{source:?} selected {master:?}"); + assert!(candidate_names(&binding).contains(&master)); } // An observed name is still retained byte for byte: a caller writes it back. let catalog = ledgers(&["Alpha Traders ", "Beta Supply"]); assert_eq!(catalog.names().next(), Some("Alpha Traders ")); let binding = bind_one_name(&catalog, "Alpha Traders"); - assert_eq!(binding.bound_name(), Some("Alpha Traders ")); + assert_eq!(binding.bound_name(), None); + assert_eq!(candidate_names(&binding), ["Alpha Traders "]); assert_eq!( binding.source_name, "Alpha Traders", "a source name is recorded as the document wrote it" @@ -250,16 +238,14 @@ fn an_exact_name_binds() { } #[test] -fn case_binds_but_an_unverified_fold_only_suggests() { - // ASCII case folding is measured, so it resolves. +fn every_fold_is_a_candidate_in_an_unscoped_catalog() { + // ASCII case folding was observed at one gateway, but the generic catalog + // cannot turn that observation into authority. let cased = ledgers(&["Alpha Traders", "Beta Supply"]); - assert_eq!( - bind_one_name(&cased, "ALPHA traders").status, - BindingStatus::Bound { - catalog_name: "Alpha Traders".to_string(), - basis: BindingBasis::NormalizedName, - } - ); + let folded = bind_one_name(&cased, "ALPHA traders"); + assert_eq!(folded.bound_name(), None); + assert_eq!(reason(&folded), UnboundReason::NearMiss); + assert_eq!(candidate_names(&folded), ["Alpha Traders"]); // An en dash, a collapsed whitespace run and leading whitespace are all on // §9.4b's unverified list. The wide fold still reaches the master, so it is @@ -395,6 +381,57 @@ fn a_single_candidate_still_does_not_bind() { assert_eq!(binding.bound_name(), None); } +#[test] +fn a_narrow_fold_candidate_survives_a_wide_fold_cap() { + // `comparison_key` folds Unicode case while the historical candidate index + // intentionally does not. Twenty-five slash spellings therefore share the + // wide key, while the space spelling is the only narrow-fold candidate. + // The report must retain the latter before applying the listing cap. + let upper = ['Α', 'Β', 'Γ', 'Δ', 'Ε', 'Ζ']; + let lower = ['α', 'β', 'γ', 'δ', 'ε', 'ζ']; + let mut names = vec!["ΑΒΓΔΕ Ζ".to_string()]; + for mask in 1..=MAX_CANDIDATES_PER_ENTITY { + let spelling = upper + .iter() + .zip(lower) + .enumerate() + .map(|(bit, (upper, lower))| { + if mask & (1 << bit) == 0 { + *upper + } else { + lower + } + }) + .collect::(); + names.push(format!( + "{}{}", + &spelling[..spelling.len() - 'Ζ'.len_utf8()], + "/ζ" + )); + } + names.extend([ + "Identifier Alpha (5550001234)".to_string(), + "Identifier Beta (5550001234)".to_string(), + ]); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid catalog"); + let source = + SourceEntity::with_identifier_hints(0, "ΑΒΓΔΕ/Ζ", ["5550001234"]).expect("valid source"); + let binding = bound(&catalog, &[source]).entities()[0].clone(); + let candidates = binding + .unresolved() + .expect("candidate-only fold") + .candidates + .listed(); + assert_eq!(candidates.len(), MAX_CANDIDATES_PER_ENTITY); + assert_eq!(candidates[0].catalog_name, "Identifier Alpha (5550001234)"); + assert_eq!(candidates[1].catalog_name, "Identifier Beta (5550001234)"); + assert!(candidates[..2] + .iter() + .all(|candidate| candidate.rule == CandidateRule::SharedIdentifier)); + assert_eq!(candidates[2].catalog_name, "ΑΒΓΔΕ Ζ"); + assert_eq!(candidates[2].rule, CandidateRule::NormalizedEqual); +} + #[test] fn a_truncated_source_name_surfaces_the_longer_master() { let catalog = ledgers(&["DELTA WHOLESALE PLACEHOLDER", "Beta Supply"]); @@ -529,11 +566,9 @@ fn a_decisive_identifier_pointing_elsewhere_still_outranks_a_byte_exact_name() { } #[test] -fn space_hyphen_and_slash_are_one_separator_in_both_directions() { - // `TALLY_PROTOCOL_REFERENCE.md` §9.4d, measured on licensed TallyPrime 7.1 - // by naming each spelling in a voucher and reading the day book back to see - // which master it reached. Both hyphen directions matched, and so did a - // slash — so this fold is symmetric, and one key per side is enough. +fn historical_separator_index_offers_candidates_in_both_directions() { + // The symmetric index is candidate discovery only. Its qualified Silver + // observation did not establish a generic reverse-direction rule. for (master, source) in [ ("BRIDGE-PROBE-LEDGER-A", "BRIDGE PROBE LEDGER A"), ("BRIDGE PROBE LEDGER A", "BRIDGE-PROBE-LEDGER-A"), @@ -541,20 +576,16 @@ fn space_hyphen_and_slash_are_one_separator_in_both_directions() { ("BRIDGE/PROBE/LEDGER/A", "BRIDGE-PROBE-LEDGER-A"), ] { let catalog = ledgers(&[master, "Beta Supply"]); - assert_eq!( - bind_one_name(&catalog, source).bound_name(), - Some(master), - "{source} did not reach {master}" - ); + let binding = bind_one_name(&catalog, source); + assert_eq!(binding.bound_name(), None, "{source} selected {master}"); + assert!(candidate_names(&binding).contains(&master)); } - // `X - Y` is a common ledger convention, and it needs the separator step - // and the whitespace-run step together. Both are measured, so it resolves. + // `X - Y` still appears as a candidate when those transformations compose. let spaced_hyphen = ledgers(&["Bank - HDFC Current", "Beta Supply"]); - assert_eq!( - bind_one_name(&spaced_hyphen, "Bank HDFC Current").bound_name(), - Some("Bank - HDFC Current") - ); + let binding = bind_one_name(&spaced_hyphen, "Bank HDFC Current"); + assert_eq!(binding.bound_name(), None); + assert_eq!(candidate_names(&binding), ["Bank - HDFC Current"]); // An en dash and an underscore were **sent and rejected**. They are not // separators to Tally, however much they look like them, so they may only @@ -644,17 +675,13 @@ fn the_master_fold_stops_where_tally_stops() { #[test] fn a_trailing_space_never_claims_byte_equality() { // `Bank ` against live `Bank` must not report exact: the import file would - // still carry the trailing space. Normalized is the correct, loud outcome — - // the write gate refuses it. + // still carry the trailing space. The generic catalog has no fold authority, + // so the observed spelling is offered for an operator to select. let catalog = ledgers(&["Bank"]); let binding = bind_one_name(&catalog, "Bank "); - assert_eq!( - binding.status, - BindingStatus::Bound { - catalog_name: "Bank".to_string(), - basis: BindingBasis::NormalizedName, - } - ); + assert_eq!(binding.bound_name(), None); + assert_eq!(reason(&binding), UnboundReason::NearMiss); + assert_eq!(candidate_names(&binding), ["Bank"]); assert_eq!( binding.source_name, "Bank ", "the requested value is echoed verbatim" @@ -950,8 +977,12 @@ fn repeating_one_source_name_does_not_repeat_the_search_or_change_the_answer() { assert_eq!(report.entities()[0].bound_name(), Some("Acme Branch 00007")); assert_eq!( report.entities()[1].bound_name(), - Some("Acme Branch 00007"), - "a normalized hit is still a hit" + None, + "a folded hit must remain a candidate" + ); + assert_eq!( + candidate_names(&report.entities()[1]), + ["Acme Branch 00007"] ); // Same source *name*, different identifier hints. The key is identical, so @@ -1158,16 +1189,13 @@ fn a_date_range_is_dates_even_after_its_separator_is_removed() { } #[test] -fn a_stock_item_may_suggest_on_a_fold_but_not_resolve_on_one() { - // §9.4d measured **ledgers**. Whether stock items match by the same rule - // was never sent, so the same folded pair that resolves for a ledger may - // only be offered for a stock item. +fn every_catalog_class_offers_a_fold_without_resolving_it() { + // A generic catalog has no scope-qualified authority for either class. let folded = ["Sales-Item", "Beta Supply"]; let ledger = MasterCatalog::new(MasterClass::Ledger, folded).expect("valid"); - assert_eq!( - bind_one_name(&ledger, "sales item").bound_name(), - Some("Sales-Item") - ); + let ledger_binding = bind_one_name(&ledger, "sales item"); + assert_eq!(ledger_binding.bound_name(), None); + assert_eq!(candidate_names(&ledger_binding), ["Sales-Item"]); let items = MasterCatalog::new(MasterClass::StockItem, folded).expect("valid"); let source = SourceEntity::new(0, "sales item").expect("valid"); @@ -2133,6 +2161,11 @@ fn a_bound_status_serializes_without_a_score_field() { assert!(json.get("confidence").is_none()); } +#[test] +fn historical_normalized_basis_is_rejected_by_current_bindings() { + assert!(serde_json::from_str::("\"normalized_name\"").is_err()); +} + // --------------------------------------------------------------------------- // Characterization against a realistically shaped book // @@ -2220,18 +2253,21 @@ fn fabricated_document() -> Vec<(&'static str, Option<&'static str>, Expected)> // Named exactly as the book spells it. ("Cash", None, Expected::Bound("Cash")), ("CGST OUTPUT 9%", None, Expected::Bound("CGST OUTPUT 9%")), - // Case noise alone resolves, and so does spacing noise: §9.4d measured - // leading whitespace and a collapsed run matching on licensed 7.1. - ("cgst output 9%", None, Expected::Bound("CGST OUTPUT 9%")), + // Folded spelling is shown for review; it is not a selected master. + ( + "cgst output 9%", + None, + Expected::Unbound(UnboundReason::NearMiss), + ), ( " cgst output 9% ", None, - Expected::Bound("CGST OUTPUT 9%"), + Expected::Unbound(UnboundReason::NearMiss), ), ( "beta placeholder trading co", None, - Expected::Bound("BETA PLACEHOLDER TRADING CO"), + Expected::Unbound(UnboundReason::NearMiss), ), // The engagement case: the source names the party its own way and // carries the number in a separate column. Name matching would offer @@ -2321,8 +2357,8 @@ fn a_document_against_a_realistic_book_binds_only_where_a_human_would() { // The shape of the answer, pinned so a loosened threshold moves a number. let totals = report.totals(); assert_eq!(totals.requested, 13); - assert_eq!(totals.bound, 8); - assert_eq!(totals.ambiguous, 3); + assert_eq!(totals.bound, 5); + assert_eq!(totals.ambiguous, 6); assert_eq!(totals.unmatched, 2); assert_eq!(totals.requested, totals.bound + totals.unbound); } @@ -2448,22 +2484,12 @@ fn no_mutation_of_a_master_name_ever_binds_to_a_different_master() { checked > 900, "the sweep must actually cover the book: {checked}" ); - // Most of this book's mutations are spacing and separator noise, and §9.4d - // measured Tally folding all of it on the SKU this writes to — so most of - // them resolve again. The per-case invariant above still holds and is the - // point: anything the wide fold reaches is bound or shown, never hidden. - // - // On this book the two folds now agree on every mutation, so the sweep does - // **not** exercise the gap between them. The cases that still separate them - // — an en dash, an underscore, an NFD spelling — are covered by - // `space_hyphen_and_slash_are_one_separator_in_both_directions` and - // `two_encodings_of_one_name_are_two_masters_to_tally_and_so_to_this` - // instead, and this comment exists so nobody reads a green sweep as - // evidence about them. + // Exact spellings bind; historical-index mutations remain candidates. The + // invariant is that a mutation never reaches another master and any name + // the index finds is still shown. assert!( - self_bound * 2 > checked, - "most mutations should resolve once the fold matches the gateway: \ - {self_bound} bound of {checked}" + self_bound > 0, + "the sweep must retain byte-exact bindings: {self_bound} bound of {checked}" ); assert!( self_bound + self_offered > checked * 2 / 3, diff --git a/src-tauri/src/agent_catalog.rs b/src-tauri/src/agent_catalog.rs index c1e158bd..d6f03723 100644 --- a/src-tauri/src/agent_catalog.rs +++ b/src-tauri/src/agent_catalog.rs @@ -148,7 +148,7 @@ pub(super) fn registered_tool_definitions(import_enabled: bool, writes_enabled: json!({"type":"object", "additionalProperties":false}), ), "validate_masters" => ( - "Bind 1–100 nonblank ledger names (at most 1024 characters each) against the live catalogue. An identifier embedded in a master name is matched before the name itself. `match_state` is exact, normalized, identifier, near_miss or missing; only `exact` is admitted by build_import_xml, and a bound row alone carries `exact_live_spelling`. A near-miss is never resolved: it returns candidates with the rule that surfaced each, bounded to 25 names and 8192 UTF-8 bytes per requested name, with candidate_count, candidate_count_is_lower_bound and truncation reported. When candidate_count_is_lower_bound is true, the count is a conservative lower bound and must be shown as at least that many candidates. There is no ranking and no score.", + "Bind 1–100 nonblank ledger names (at most 1024 characters each) against the live catalogue. An identifier embedded in a master name is matched before the name itself. `match_state` is exact, identifier, near_miss or missing; folded names remain near_miss candidates because this catalogue has no qualified scope to bind them. Only `exact` is admitted by build_import_xml, and a bound row alone carries `exact_live_spelling`. A near-miss is never resolved: it returns candidates with the rule that surfaced each, bounded to 25 names and 8192 UTF-8 bytes per requested name, with candidate_count, candidate_count_is_lower_bound and truncation reported. When candidate_count_is_lower_bound is true, the count is a conservative lower bound and must be shown as at least that many candidates. There is no ranking and no score.", json!({"type":"object", "additionalProperties":false, "required":["company_guid","ledgers"], "properties":{"company_guid":{"type":"string"},"ledgers":{"type":"array","minItems":1,"maxItems":agent_import::MAX_MASTER_NAMES,"items":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"}}}}), ), "build_import_xml" => ( diff --git a/src-tauri/src/agent_import.rs b/src-tauri/src/agent_import.rs index 20403ac9..23c44dca 100644 --- a/src-tauri/src/agent_import.rs +++ b/src-tauri/src/agent_import.rs @@ -455,7 +455,7 @@ impl Server { payload: json!({"company": company_json(&company, std::slice::from_ref(&company)), "result": { "state":"refused", "reason":"masters_not_exact", "masters":report, "catalogue_evidence_sha256":sha256_json(&catalogue), - "next_step":"Use the exact live spelling from validate_masters, then build a new batch. No file was written." + "next_step":master_recovery_guidance(&report) }}), evidence: accumulated.clone(), company_guid: Some(payload.company_guid), @@ -1644,7 +1644,6 @@ fn master_match_json(binding: &EntityBinding) -> Value { // nothing else. let match_state = match basis { BindingBasis::ExactName => "exact", - BindingBasis::NormalizedName => "normalized", BindingBasis::Identifier => "identifier", }; json!({ @@ -1711,6 +1710,31 @@ fn master_match_json(binding: &EntityBinding) -> Value { } } +fn master_recovery_guidance(report: &[Value]) -> String { + let mut guidance = Vec::new(); + if report + .iter() + .any(|master| master["match_state"] == "identifier") + { + guidance + .push("For identifier-bound entries, copy exact_live_spelling from this fresh result."); + } + if report + .iter() + .any(|master| master["match_state"] == "missing") + { + guidance.push("For missing ledgers, correct the source spelling or have an operator create the legitimate ledger externally, then run validate_masters again."); + } + if report + .iter() + .any(|master| master["match_state"] == "near_miss") + { + guidance.push("For near-misses, have an operator explicitly select the intended ledger and run validate_masters again; do not copy a candidate automatically."); + } + guidance.push("After operator review, update the payload to each confirmed exact live spelling and run validate_masters again before building. No file was written."); + guidance.join(" ") +} + fn render_import_xml(company: &str, vouchers: &[ImportVoucher], batch_id: &str) -> String { let messages = vouchers .iter() diff --git a/src-tauri/src/agent_import_tests.rs b/src-tauri/src/agent_import_tests.rs index 9a5eba6d..0f816d14 100644 --- a/src-tauri/src/agent_import_tests.rs +++ b/src-tauri/src/agent_import_tests.rs @@ -278,15 +278,15 @@ fn schema_balance_matcher_rendering_and_ledger_append_are_fail_closed() { validate_payload(&unbalanced), Err("voucher_not_balanced".to_string()) ); - // ASCII case and one trailing space are transformations - // `TALLY_PROTOCOL_REFERENCE.md` §9.4b measured Tally performing, so they - // name the same live ledger: they bind and report its exact spelling. Only - // byte equality is `exact`, which is what build_import_xml admits. + // The generic agent catalogue carries no scope-qualified fold authority. + // It may offer the observed spelling, but only byte equality is `exact`. for wanted in ["bank ", "bank", "BANK"] { let matched = one_master_match(wanted, &["Bank"]); - assert_eq!(matched["match_state"], "normalized"); + assert_eq!(matched["match_state"], "near_miss"); + assert_eq!(matched["reason"], "master_binding_near_miss"); + assert!(matched.get("exact_live_spelling").is_none()); assert_eq!( - matched["exact_live_spelling"][super::super::PARTY_NAME_MARKER], + matched["candidates"][0]["name"][super::super::PARTY_NAME_MARKER], "Bank" ); } @@ -1615,6 +1615,23 @@ fn nothing_defensible_is_reported_missing_with_no_candidate() { assert!(matched["candidates"].as_array().unwrap().is_empty()); } +#[test] +fn import_recovery_guidance_names_the_state_and_next_safe_read() { + let identifier = one_master_match("GAMMA 5550000001", &["GAMMA (5550000001)"]); + assert_eq!(identifier["match_state"], "identifier"); + assert!(identifier.get("reason").is_none()); + let guidance = master_recovery_guidance(&[ + identifier, + serde_json::json!({"match_state":"missing"}), + serde_json::json!({"match_state":"near_miss"}), + ]); + assert!(guidance.contains("exact_live_spelling")); + assert!(guidance.contains("legitimate ledger externally")); + assert!(guidance.contains("explicitly select")); + assert!(guidance.contains("update the payload to each confirmed exact live spelling")); + assert!(guidance.contains("validate_masters again before building")); +} + #[tokio::test] async fn import_bounds_distinct_ledger_names_before_tally_without_reducing_voucher_limit() { let mut repeated = payload(); @@ -2174,3 +2191,29 @@ async fn current_dispatch_persists_its_reconciliation_verdict_before_returning_t 50 ); } + +#[test] +fn master_match_byte_cap_retains_narrow_fold_candidate() { + let suffix = "α".repeat(880); + let source = format!("αβγδεζ/{suffix}"); + let narrow = format!("αβγδεζ {suffix}"); + let upper = ['Α', 'Β', 'Γ', 'Δ', 'Ε', 'Ζ']; + let lower = ['α', 'β', 'γ', 'δ', 'ε', 'ζ']; + let mut names = vec![narrow.clone()]; + for mask in 1..=10 { + let prefix = upper + .iter() + .zip(lower) + .enumerate() + .map(|(bit, (u, l))| if mask & (1 << bit) == 0 { *u } else { l }) + .collect::(); + names.push(format!("{prefix}/{suffix}")); + } + assert!(names.iter().map(String::len).sum::() > 8192); + let borrowed = names.iter().map(String::as_str).collect::>(); + let rendered = one_master_match(&source, &borrowed); + let listed = rendered["candidates"].as_array().unwrap(); + assert_eq!(listed[0]["name"][super::super::PARTY_NAME_MARKER], narrow); + assert!(listed.iter().all(|v| v["rule"] == "normalized_equal")); + assert_eq!(rendered["candidates_truncated"], true); +} diff --git a/src-tauri/src/source_draft/catalog.rs b/src-tauri/src/source_draft/catalog.rs index 82d9a0a9..a9c5d9c7 100644 --- a/src-tauri/src/source_draft/catalog.rs +++ b/src-tauri/src/source_draft/catalog.rs @@ -165,7 +165,9 @@ pub(super) struct CatalogApplySnapshot { /// Binds every source entry's observed ledger name against the captured /// catalog. Advisory only: an empty or unusable capture narrows nothing rather /// than failing the read the operator just performed, and every returned name -/// is still revalidated by the apply path before it can become a target. +/// is still revalidated by the apply path before it can become a target. A +/// folded name is a candidate only; this generic catalog has no +/// scope-qualified authority to select it. fn source_entry_bindings( source: &crate::source_draft_xml::ParsedSource, targets: &[String], @@ -720,11 +722,14 @@ mod tests { assert_eq!(state, "complete"); assert_eq!(bindings.len(), 3); - // Case alone does not defeat a bind, and the live spelling is named. + // A scoped gateway observation does not make this generic catalogue + // authoritative: folding can suggest the live spelling, never select it. assert_eq!(bindings[0].row_position, 1); assert_eq!(bindings[0].entry_position, 1); - assert_eq!(bindings[0].bound_target.as_deref(), Some("Alpha Traders")); - assert_eq!(bindings[0].bound_basis, Some(BindingBasis::NormalizedName)); + assert_eq!(bindings[0].bound_target, None); + assert_eq!(bindings[0].bound_basis, None); + assert_eq!(bindings[0].unbound_reason, Some("master_binding_near_miss")); + assert_eq!(bindings[0].candidates, ["Alpha Traders", "GAMMA ALPHA"]); // The number the operator buried in the ledger name decides where the // name offers a wrong candidate. @@ -798,7 +803,8 @@ mod tests { "20260901", // Byte-exact against a captured name. "Cash1", - // Case and separator folding, against a captured name. + // A fold against a captured name still only suggests a candidate: + // this consumer's catalogue does not carry the observation's authority. "wr2-sales-1", // `AND` for `&` is rejected by the gateway, so it must not bind. "Profit AND Loss A/c0", @@ -816,8 +822,13 @@ mod tests { assert_eq!(bindings[0].bound_target.as_deref(), Some("Cash")); assert_eq!(bindings[0].bound_basis, Some(BindingBasis::ExactName)); - assert_eq!(bindings[1].bound_target.as_deref(), Some("WR2 Sales")); - assert_eq!(bindings[1].bound_basis, Some(BindingBasis::NormalizedName)); + assert_eq!(bindings[1].bound_target, None); + assert_eq!(bindings[1].bound_basis, None); + assert_eq!(bindings[1].unbound_reason, Some("master_binding_near_miss")); + assert_eq!( + bindings[1].candidates, + ["WR2 Sales", "WR2 XML Café Naïve Ledger 01A01A2F"] + ); // `&` is not folded — §9.4d sent `AND` for `&` and Tally rejected it — // so this refuses against a real catalogue rather than in theory, and @@ -845,12 +856,12 @@ mod tests { "the committed fixture no longer matches what the binder emits" ); - // The evidence fields are the capture's own, not placeholders. They - // come from the retained capture's metadata sidecar and from this - // source document's digest, and are asserted here so the fixture - // cannot quietly go back to zeros while still calling itself a - // capture. `capture_id` has no captured counterpart — it is minted - // locally per read — so it stays a fixed synthetic UUID. + // The evidence fields belong to the retained **catalogue** capture; + // the source XML is authored test input and its digest is only that + // input's identity. The fixture's provenance records this split so it + // cannot be presented as an end-to-end source-draft observation. + // `capture_id` has no captured counterpart — it is minted locally per + // read — so it stays a fixed synthetic UUID. let provenance: serde_json::Value = serde_json::from_str(include_str!( "../../crates/bridge-tally-protocol/tests/fixtures/agent/native-ledger-catalogue.json" )) @@ -870,7 +881,7 @@ mod tests { assert_eq!( committed["source_sha256"], serde_json::Value::String(source.sha256.clone()), - "the fixture no longer carries this source document's digest" + "the fixture no longer carries the authored source input digest" ); }