From b98569ff26273b048f8c3bd4355d3f23d8d37b7c Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Thu, 10 Sep 2026 21:45:27 +0530 Subject: [PATCH 01/91] Bind source entities to masters deterministically in the shared crate Every failure across four document-import engagements was binding a document's entities to the target book's masters, never reading the document. Bridge was growing two answers to it: an MCP-private prefix matcher that named one near-miss candidate as `exact_live_spelling`, and a desktop screen that correctly ranks nothing but narrows nothing either. Add `bridge_tally_core::master_binding` as the single contract both surfaces consume, per ADR 0016. It matches an identifier embedded in a master name before the name itself, binds only where a rule is unique on both sides, and never resolves a near-miss: it reports candidates with the rule that surfaced each, and no score. An empty catalogue is a typed refusal rather than a report full of "missing". Deletes `master_match` and `master_key` from agent_import and moves unicode-normalization down a layer with them. The write gate is unchanged: build_import_xml and the approved-post recheck still admit byte-exact names only. Co-Authored-By: Claude Opus 5 --- docs/adr/0016-master-binding-authority.md | 252 +++++ .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 18 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 1 - src-tauri/crates/bridge-tally-core/Cargo.toml | 1 + src-tauri/crates/bridge-tally-core/src/lib.rs | 1 + .../bridge-tally-core/src/master_binding.rs | 905 ++++++++++++++++++ .../src/master_binding_tests.rs | 507 ++++++++++ src-tauri/src/agent_catalog.rs | 2 +- src-tauri/src/agent_import.rs | 149 ++- src-tauri/src/agent_import_post.rs | 2 +- src-tauri/src/agent_import_tests.rs | 113 ++- src-tauri/src/source_draft/catalog.rs | 151 +++ src/source-draft-types.ts | 16 + tools/Cargo.lock | 10 + 16 files changed, 2046 insertions(+), 86 deletions(-) create mode 100644 docs/adr/0016-master-binding-authority.md create mode 100644 src-tauri/crates/bridge-tally-core/src/master_binding.rs create mode 100644 src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md new file mode 100644 index 00000000..61692393 --- /dev/null +++ b/docs/adr/0016-master-binding-authority.md @@ -0,0 +1,252 @@ +# ADR 0016: Master binding is deterministic, identifier-first, and never auto-resolves + +## Status + +Accepted for the shared binding contract in `bridge-tally-core`, consumed by the +agent/MCP layer and by the desktop source-draft flow. Master creation, ledger or +stock-item write authority, voucher generation, posting, and any model-assisted +or scored matching remain rejected without separate evidence. + +## Context + +Four document-import engagements have run to completion — one failed, three +clean, 152 vouchers, zero rejections. **Not one failure was OCR, parsing, or +model quality. Every failure was binding the document's entities to the target +book's masters.** + +- One engagement rejected 61 vouchers: seven ledger masters were missing, four + of them near-misses of ledgers that already existed, and suspense was posted + to an account that did not exist. +- One bank engagement survived only because ambiguous truncated payees were + parked in suspense rather than guessed. +- One sales engagement met five missing stock items, three near-duplicate sales + ledgers differing by one character and word order, and a customer whose ledger + name differed from the source name entirely. **Fuzzy name matching offered + three candidates and all three were the wrong person.** Only a mobile number + the operator had embedded in the ledger name identified them, and it matched + exactly. + +Bridge has two surfaces that need this and is currently growing two answers: + +- `agent_import.rs::master_match` (MCP `validate_masters`) compares a + case-and-whitespace-folded key by equality or prefix, and — for a near-miss — + emits `exact_live_spelling: candidates.first()`. That field names one + candidate as *the* live spelling with no evidence that it is; it is a silent + auto-resolution of exactly the case that caused the 61-voucher failure. +- PR #276 gave the desktop the opposite and correct behaviour: an operator loads + the observed ledger list and explicitly assigns a target, with a fresh reread + proving the selection still exists. It deliberately ranks nothing — but it + also narrows nothing, so the operator faces the entire catalog per entry. + +Neither surface matches on an embedded identifier, which is the one rule that +would have decided the case that defeated fuzzy matching. + +## Decision + +Binding is a **pure, deterministic function in `bridge-tally-core`** over +(one company's observed masters of one class, the entities named by one source +document). It performs no I/O, holds no transport handle, calls no model, and +depends on nothing above `bridge-tally-primitives`. Both surfaces consume it; +neither reimplements it. + +### 1. Inputs are valid by construction + +`MasterCatalog::new(class, names)` and `SourceEntity::new(...)` parse at the +boundary and fail closed with a typed `MasterBindingError`. `bind()` then takes +already-valid inputs and returns a report with no `Result`, so no caller can +re-check or compensate differently (P3). + +The constructor refuses, rather than degrades, on: + +- **an empty catalog** — `CatalogEmpty`. Binding against a book that was never + read is the May failure exactly, and it is now a typed error rather than a + report full of "missing"; +- **a catalog carrying the same name twice** — `CatalogDuplicateName`. If a name + does not identify one master, nothing downstream is meaningful; +- **an identifier hint that yields no identifier** — `IdentifierHintUnusable`. A + hint that silently does nothing is a trap (P7); +- bounds violations on entry count, entity count, and name length. + +A softer collision — two masters differing only in case, whitespace runs, or +dash and quote style — does **not** fail the catalog. It is carried as a +per-entity ambiguity instead, so every other master still binds and the +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, the +rules are identical for both, and the class is carried only so a report cannot +be applied to the wrong catalog. + +### 2. The identifier is the key; the name is a hint + +Operators bury phone numbers, account numbers, and part codes inside master +names. Where such an identifier is present it is matched **before** any name +comparison, because a name comparison on the same pair is actively misleading. + +Two identifier shapes are extracted, deterministically, from both source names +and master names, and additionally accepted from the caller when the source +document carries an identifier outside the name (a statement's payment +reference, say): + +- **Numeric** — a maximal digit run, allowing internal hyphens and slashes, of + at least `MIN_NUMERIC_IDENTIFIER_DIGITS` (8) digits. Canonical form is the + digits alone, so a punctuated account number and a plain one agree. Internal + *spaces* are deliberately not allowed: fusing separated digit groups would + manufacture identifiers out of unrelated numbers, so a spaced value fails + closed to a near-miss instead. Eight digits is the threshold at which a year, + a rate, a house number and a masked last-four cannot qualify — a last-four + written as digits falls through to near-miss rather than binding two accounts + that share four digits. +- **Code** — a token holding at least one letter and at least + `MIN_CODE_IDENTIFIER_DIGITS` (2) digits, of at least + `MIN_CODE_IDENTIFIER_CHARS` (4) alphanumeric characters. Canonical form is + uppercase alphanumerics, so a punctuated part number and an unpunctuated one + agree. + +One narrow exclusion applies to the numeric shape: an eight-digit run that reads +as a calendar date in 1900–2199 is a date, not an identifier. Without it two +unrelated period-labelled masters fuse on their period. The exclusion can only +make a bind less likely, never more, which is the safe direction for a rule +whose failure mode is posting against the wrong party. + +An identifier binds only when it is **unique on both sides**: exactly one +master in the catalog carries it, and the entity's identifiers select exactly +one master overall. Any conflict is `Ambiguous`, never a bind. This keeps the +rule safe in the case that motivates it — a shared identifier is evidence of a +naming collision the operator must see, not licence to choose. + +Identifier-first has one more guard. Where a decisive identifier points at one +master while the entity's name is byte-equal to a *different* master, two strong +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 + +`Exact` is byte equality with the observed master name. `Normalized` is equality +under a comparison key that applies NFC, folds Unicode dash and quote variants +to ASCII, lowercases, and collapses whitespace — and only when exactly one +master shares that key. Nothing else binds. There is no edit distance, no +phonetic key, no token stemming, and no similarity threshold anywhere in the +implementation. + +### 4. Near-misses produce candidates and never resolve + +Every non-binding entity carries its candidates, each labelled with the **rule +that produced it** — `SharedIdentifier`, `NormalizedEqual`, `SourcePrefix`, +`CatalogPrefix`, or `SharedToken`. Candidates are ordered by rule and then by +name; **no candidate is marked best, first-choice, or `exact_live_spelling`, +and no numeric score is emitted at all.** + +A score is rejected as a matter of contract, not of tuning. A score invites a +threshold, a threshold auto-resolves, and auto-resolution is what put money +against the wrong parties. The vocabulary is therefore a *basis* — a fact about +which rule fired — and never a confidence value (P6: the marker is recorded, and +it records what was observed). + +`SharedToken` suppresses tokens that occur in more than +`COMMON_TOKEN_PERCENT` (10%) of a catalog of at least `COMMON_TOKEN_MIN_CATALOG` +(20) entries, so a catalog-wide word cannot pull in every master. The +suppression is measured from the catalog rather than from a built-in word list, +which keeps it free of language and domain assumptions. + +Candidates are capped at `MAX_CANDIDATES_PER_ENTITY` (25) with the true +`candidate_count` and an explicit `candidates_truncated` flag retained, so a +truncated list is never mistaken for a short one. + +### 5. Status vocabulary + +Per entity, exactly one of: + +| status | meaning | +| --- | --- | +| `Bound { catalog_name, basis }` | one master, decided by `Identifier`, `ExactName`, or `NormalizedName` | +| `Ambiguous { candidates, .. }` | more than one master is defensible, including every identifier conflict | +| `Unmatched { candidates, .. }` | no rule produced a candidate | + +`Ambiguous` and `Unmatched` are the **unbound list, which is the product**. Each +unbound entry carries the source name as given, a stable `safe_reason_code`, its +extracted identifiers as `unresolved_identity`, and its candidates. "Here is +what I could not bind, and why" is the operator's actual work item; it is not an +error path and is not logged as a failure. + +### 6. A fallback binding is constructed, never inferred + +An ambiguous entity must remain postable. `FallbackBinding::assign` accepts an +**unbound** entry and a catalog-verified fallback master (a suspense ledger), +and retains the entity's `unresolved_identity` so a later reallocation journal +can find it without re-reading the source. It cannot be constructed from a bound +entity, so "silently rebound something that already matched" is not a +representable state (P2). Binding itself never emits a fallback. + +### 7. Totals prove the run + +`BindingReport::totals()` reports `requested`, `bound`, `ambiguous`, +`unmatched`, and `unbound`. `requested == bound + unbound` and +`unbound == ambiguous + unmatched` are invariants asserted by test, matching the +control-total discipline that proved every clean engagement. + +### 8. Identity stays where identity is already proven + +The report names masters by their **observed name only**. It holds no GUID and +grants no authority. A caller that intends to act on a binding re-reads the +catalog and revalidates the selection through the existing admission path — +`StandardLedgerCatalog::bind_selected` plus a fresh +`StandardLedgerCatalogBinding::matches` — exactly as PR #276 already requires. +This preserves that PR's rule that matching text alone is never a selected or +approved target, and keeps GUIDs out of a portable crate that has no company +scope to check them against. + +Consequently a binding is a **proposal**, never an approval. It selects no +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. +- `build_import_xml` and the approved-post recheck still admit **`exact` only**. + The import file carries the name verbatim, so a normalized or identifier bind + informs the operator without widening what may be written. This PR does not + move the write gate. +- The MCP result reports an unbound entity's `unresolved_identity` wrapped in + the same party-name marker as every other name, so egress redaction treats it + identically. It adds no exposure: those identifiers are extracted from the + requested name the same result already echoes. +- The desktop catalog load returns an advisory binding per source entry, so the + operator sees the few relevant ledgers rather than all of them. It confers no + authority: assignment still runs the unchanged apply path, which rereads the + catalog and proves the selection is current. An unusable capture narrows + nothing rather than failing a read the operator just performed. +- Stock items are covered by contract before a stock-item catalog read exists. + When that read lands it supplies names to the same constructor; nothing in + this contract changes. Until then the shipped consumers pass `Ledger`, so the + stock-item half of the recorded failure is designed for but not yet reachable + from a screen. +- Binding is pure computation over already-observed data, so P1's live-evidence + requirement is satisfied upstream by the catalog read that produces its input. + Its own tests are fabricated from a placeholder alphabet: they establish the + behaviour of the rules, and are not, and may not be presented as, evidence + about any Tally instance. + +## Alternatives rejected + +- **Fuzzy or scored matching (edit distance, trigram, phonetic).** Directly + disproven: on the case that mattered its three best candidates were three + different wrong people, and a fourth-ranked exact identifier was present. +- **Auto-resolving a single candidate.** A single candidate is exactly the + four-near-miss situation that rejected 61 vouchers. Uniqueness of a *guess* is + not evidence. +- **Building this in the MCP and migrating later.** The two surfaces would + diverge before the migration; the divergence has already begun in + `master_match` and this ADR ends it rather than duplicating it. +- **Putting binding in `bridge-tally-protocol`.** Binding parses no wire format + and needs no XML. `bridge-tally-core` is the portable contract layer and holds + the analogous reconciliation logic (P8: dependencies point inward). diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 53d05efd..15c41e76 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": "208002baf29bae14a5268ee8c5535f0c2bff6ccb37b749b7a3e51226393394ce", + "compatibility_surface_sha256": "0aee44725e419e3f677a5e19062fd716d4448c123878eb10d0c4c8b3e8a18b19", "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 dfce7d92..5510d3be 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -123,15 +123,15 @@ }, { "path": "src-tauri/Cargo.lock", - "sha256": "929a359a24c809dff87405f8103fe648ae60dba0aaba3229911ec694683708d7" + "sha256": "1b6484e09fa0cc08355dfc0cccda5abbbb4651426081401ab63caccf372b5245" }, { "path": "src-tauri/Cargo.toml", - "sha256": "5cfe6c1fba7b20dd7d7a65ffc96039f41be9d7d55129abe071f470d0b10baf5a" + "sha256": "d4071736b6a6e5cc10f8252c36c0de2cfc48de6c01a03da8e0cb95dfeed240f2" }, { "path": "src-tauri/crates/bridge-tally-core/Cargo.toml", - "sha256": "67868c232e5fcb21b8e0098732b8cb92f87353f90460cf21090feeb262ca1c31" + "sha256": "512cfa03c1a126c36433b6de54a9fabdd822d8d1ac5db8f8d3e2ef5e7ec370e0" }, { "path": "src-tauri/crates/bridge-tally-core/src/bills_reconciliation.rs", @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", - "sha256": "fe515176a64d96322b49843b96facfbe51c71e320ffbe03c36a8cf1860fe8249" + "sha256": "58674602eb3131c101ace7638d43cf550d74b29eb3848b0c908687bb2c9fbf9c" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -327,7 +327,7 @@ }, { "path": "src-tauri/src/agent_import.rs", - "sha256": "8f071dcccbcf498275c22e996cb5d45dbe3ae214aa694167b0aec9a2fe268100" + "sha256": "007c1c97eb65929734345fe72b8b52a9cee8ca6adcb375654080a71f6576ca3a" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -575,7 +575,7 @@ }, { "path": "src-tauri/src/source_draft/catalog.rs", - "sha256": "e81b7123d2208290a4b7c9a669731ddaecace5ec012d42c2061afae52c3383f6" + "sha256": "f283ace5705e679b9a25ea605fd53fc7775878b93065b0c552f4c4c9234d312d" }, { "path": "src-tauri/src/source_draft/files.rs", @@ -771,7 +771,7 @@ }, { "path": "src/source-draft-types.ts", - "sha256": "27d08a63153498943a0cb5506b15f6b0f9e5733e4ff0456376e212d9cf0534e8" + "sha256": "429d2b69a2948f8fdcb7da602c5b895d2dff42c3db7f48ba44fc9d10db701321" }, { "path": "src/source-draft.css", @@ -795,7 +795,7 @@ }, { "path": "tools/Cargo.lock", - "sha256": "b68a1a0d5c735459b7280657ced1b7d426039266e361ec4d75b17b933bd1e785" + "sha256": "62d922fb0c6b8b7fe1313bfb9058f1991760bfd04bfc5e28552dc4ec9ef2e11a" }, { "path": "tools/bridge-tally-compatibility/Cargo.toml", @@ -842,5 +842,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "208002baf29bae14a5268ee8c5535f0c2bff6ccb37b749b7a3e51226393394ce" + "manifest_sha256": "0aee44725e419e3f677a5e19062fd716d4448c123878eb10d0c4c8b3e8a18b19" } \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f4061eb4..a8cc6ac5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -441,7 +441,6 @@ dependencies = [ "tokio-util", "tracing", "tracing-subscriber", - "unicode-normalization", "uuid", "windows-sys 0.61.2", "x509-parser", @@ -460,6 +459,7 @@ dependencies = [ "sha2 0.11.0", "thiserror 2.0.20", "tokio", + "unicode-normalization", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e3493167..1a733526 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -89,7 +89,6 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1", features = ["v4", "serde"] } -unicode-normalization = "0.1" x509-parser = "0.18" zeroize = "1" pdf-writer = "0.15.0" diff --git a/src-tauri/crates/bridge-tally-core/Cargo.toml b/src-tauri/crates/bridge-tally-core/Cargo.toml index 09a5cbb7..d3873e4c 100644 --- a/src-tauri/crates/bridge-tally-core/Cargo.toml +++ b/src-tauri/crates/bridge-tally-core/Cargo.toml @@ -15,6 +15,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" thiserror = "2" +unicode-normalization = "0.1" [dev-dependencies] tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/src-tauri/crates/bridge-tally-core/src/lib.rs b/src-tauri/crates/bridge-tally-core/src/lib.rs index f45624f9..9860d52d 100644 --- a/src-tauri/crates/bridge-tally-core/src/lib.rs +++ b/src-tauri/crates/bridge-tally-core/src/lib.rs @@ -8,6 +8,7 @@ pub use bridge_tally_primitives::{ }; pub mod bills_reconciliation; +pub mod master_binding; mod pack_models; pub mod reconciliation; pub mod report_tie_out; diff --git a/src-tauri/crates/bridge-tally-core/src/master_binding.rs b/src-tauri/crates/bridge-tally-core/src/master_binding.rs new file mode 100644 index 00000000..88672867 --- /dev/null +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -0,0 +1,905 @@ +//! Deterministic binding of source-document entity names to one company's +//! observed masters. +//! +//! See `docs/adr/0016-master-binding-authority.md`. Three rules carry the whole +//! contract: an embedded identifier is matched before any name, nothing binds +//! unless it is unique on both sides, and a near-miss is never resolved — it is +//! reported with its candidates so an operator decides. +//! +//! This module performs no I/O, holds no company identity, and calls no model. +//! A returned binding is a proposal: a caller that intends to act on one +//! re-reads the catalog and revalidates the selection through the admission +//! path that owns identity. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use unicode_normalization::UnicodeNormalization; + +/// Most masters one catalog may carry. +pub const MAX_CATALOG_ENTRIES: usize = 20_000; +/// Most entities one binding request may name. +pub const MAX_SOURCE_ENTITIES: usize = 5_000; +/// Longest accepted master or source name, in characters. This bounds +/// pathological input; it is not a claim about what Tally accepts, and a +/// caller with a stricter contract of its own enforces that at its own +/// boundary. +pub const MAX_NAME_CHARS: usize = 16_384; +/// Most candidates retained per unbound entity. +pub const MAX_CANDIDATES_PER_ENTITY: usize = 25; +/// Most identifiers extracted from one name. +pub const MAX_IDENTIFIERS_PER_NAME: usize = 8; +/// Digits a numeric run needs before it is treated as an identifier. Eight +/// excludes a year, a rate, a house number and a masked last-four; a mobile, +/// an account number and a customer code all clear it. +pub const MIN_NUMERIC_IDENTIFIER_DIGITS: usize = 8; +/// Alphanumeric characters a mixed letter-and-digit token needs before it is +/// treated as a code identifier. +pub const MIN_CODE_IDENTIFIER_CHARS: usize = 4; +/// Digits a code identifier needs alongside at least one letter. +pub const MIN_CODE_IDENTIFIER_DIGITS: usize = 2; +/// Shortest comparison key that may take part in a prefix near-miss. +pub const MIN_PREFIX_KEY_CHARS: usize = 3; +/// Shortest token that may take part in a shared-token near-miss. +pub const MIN_TOKEN_CHARS: usize = 3; +/// Share of the catalog above which a token stops discriminating. +pub const COMMON_TOKEN_PERCENT: usize = 10; +/// Catalog size below which no token is treated as common. +pub const COMMON_TOKEN_MIN_CATALOG: usize = 20; + +/// The master class a catalog and a report belong to. Both classes have failed +/// in practice and the rules are identical for both; the class is carried so a +/// report cannot be applied against the wrong catalog. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MasterClass { + Ledger, + StockItem, +} + +/// Binding refuses rather than degrades. Every variant is a fail-closed +/// boundary check on input that was never observed, never usable, or already +/// undecidable before any matching ran. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum MasterBindingError { + /// Binding against a book that was never read is the failure this whole + /// contract exists to prevent. It is an error, not an empty report. + #[error("master catalog was empty")] + CatalogEmpty, + /// Two masters carry byte-identical names, so a name cannot identify one. + #[error("master catalog carried a duplicate name")] + CatalogDuplicateName, + #[error("master catalog exceeded its bound")] + CatalogTooLarge, + #[error("source entity list exceeded its bound")] + TooManySourceEntities, + #[error("name was blank")] + NameBlank, + #[error("name exceeded its bound")] + NameTooLong, + #[error("name carried a control character")] + NameUnsafe, + /// A caller-supplied identifier hint that yields no identifier would fail + /// silently, so it fails loudly instead. + #[error("identifier hint carried no usable identifier")] + IdentifierHintUnusable, + #[error("fallback master was not a current catalog entry")] + FallbackNotInCatalog, +} + +impl MasterBindingError { + /// A stable code safe to surface to an operator or a tool result. + pub fn safe_reason_code(&self) -> &'static str { + match self { + Self::CatalogEmpty => "master_catalog_empty", + Self::CatalogDuplicateName => "master_catalog_duplicate_name", + Self::CatalogTooLarge => "master_catalog_too_large", + Self::TooManySourceEntities => "master_source_entities_too_many", + Self::NameBlank => "master_name_blank", + Self::NameTooLong => "master_name_too_long", + Self::NameUnsafe => "master_name_unsafe", + Self::IdentifierHintUnusable => "master_identifier_hint_unusable", + Self::FallbackNotInCatalog => "master_fallback_not_in_catalog", + } + } +} + +/// The shape an identifier was recognized by. Both canonicalize away the +/// punctuation an operator happened to type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum IdentifierKind { + /// A digit run: a mobile, an account number, a numeric customer code. + Numeric, + /// A mixed letter-and-digit token: a part number, a registration code. + Code, +} + +/// One stable identifier embedded in a name, in canonical form. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +pub struct Identifier { + pub kind: IdentifierKind, + pub value: String, +} + +/// The rule that produced a candidate. There is deliberately no score: a score +/// invites a threshold, and a threshold auto-resolves the case this contract +/// exists to keep in front of a human. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CandidateRule { + /// Shares an embedded identifier, but the identifier was not decisive. + SharedIdentifier, + /// Equal under the comparison key, but the key was not unique. + NormalizedEqual, + /// The catalog name extends the source name — the source was truncated. + CatalogPrefix, + /// The source name extends the catalog name. + SourcePrefix, + /// Shares a token that discriminates within this catalog. + SharedToken, +} + +impl CandidateRule { + fn rank(self) -> u8 { + match self { + Self::SharedIdentifier => 0, + Self::NormalizedEqual => 1, + Self::CatalogPrefix => 2, + Self::SourcePrefix => 3, + Self::SharedToken => 4, + } + } +} + +/// A master an operator may choose, with the rule that surfaced it. No +/// candidate is marked best, and the order is rule-then-name, not similarity. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Candidate { + pub catalog_name: String, + pub rule: CandidateRule, +} + +/// Why an entity did not bind. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum UnboundReason { + /// One embedded identifier is carried by more than one master. + IdentifierConflict, + /// An identifier and an exact name pointed at different masters. + IdentifierNameConflict, + /// More than one master shares the comparison key. + NameAmbiguous, + /// Candidates exist but none was decisive. This is the four-near-miss case + /// that rejected a batch once; a single candidate stays here too. + NearMiss, + /// No rule produced a candidate. The master is probably missing. + NoCandidate, +} + +impl UnboundReason { + /// A stable code safe to surface to an operator or a tool result. + pub fn safe_reason_code(self) -> &'static str { + match self { + Self::IdentifierConflict => "master_binding_identifier_conflict", + Self::IdentifierNameConflict => "master_binding_identifier_name_conflict", + Self::NameAmbiguous => "master_binding_name_ambiguous", + Self::NearMiss => "master_binding_near_miss", + Self::NoCandidate => "master_binding_no_candidate", + } + } +} + +/// The evidence that decided a bind. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BindingBasis { + /// An embedded identifier unique on both sides. Checked before any name. + Identifier, + /// Byte equality with the observed master name. + ExactName, + /// Equality under the comparison key, unique in the catalog. + NormalizedName, +} + +/// What could not be bound, and why. This is the operator's work item, not an +/// error path. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Unresolved { + pub reason: UnboundReason, + /// Identifiers extracted from the source name and from caller hints, + /// retained so a fallback posting can be reallocated later without + /// re-reading the source document. + pub unresolved_identity: Vec, + pub candidates: Vec, + /// Candidates found before truncation. + pub candidate_count: usize, + pub candidates_truncated: bool, +} + +/// Exactly one outcome per source entity. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case", tag = "status")] +pub enum BindingStatus { + Bound { + catalog_name: String, + basis: BindingBasis, + }, + /// More than one master is defensible. + Ambiguous(Unresolved), + /// No master is defensible. + Unmatched(Unresolved), +} + +/// One source entity and its outcome. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct EntityBinding { + pub position: usize, + /// The name exactly as the source document gave it. + pub source_name: String, + #[serde(flatten)] + pub status: BindingStatus, +} + +impl EntityBinding { + pub fn bound_name(&self) -> Option<&str> { + match &self.status { + BindingStatus::Bound { catalog_name, .. } => Some(catalog_name.as_str()), + _ => None, + } + } + + pub fn unresolved(&self) -> Option<&Unresolved> { + match &self.status { + BindingStatus::Ambiguous(unresolved) | BindingStatus::Unmatched(unresolved) => { + Some(unresolved) + } + BindingStatus::Bound { .. } => None, + } + } +} + +/// Control totals for one run. `requested == bound + unbound` and +/// `unbound == ambiguous + unmatched` always hold. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub struct BindingTotals { + pub requested: usize, + pub bound: usize, + pub unbound: usize, + pub ambiguous: usize, + pub unmatched: usize, +} + +/// The result of one binding run. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct BindingReport { + class: MasterClass, + entities: Vec, +} + +impl BindingReport { + pub fn class(&self) -> MasterClass { + self.class + } + + pub fn entities(&self) -> &[EntityBinding] { + &self.entities + } + + /// Everything that bound. + pub fn bound(&self) -> impl Iterator { + self.entities + .iter() + .filter(|entity| matches!(entity.status, BindingStatus::Bound { .. })) + } + + /// Everything that did not — the product of this module. + pub fn unbound(&self) -> impl Iterator { + self.entities + .iter() + .filter(|entity| !matches!(entity.status, BindingStatus::Bound { .. })) + } + + pub fn totals(&self) -> BindingTotals { + let mut totals = BindingTotals { + requested: self.entities.len(), + bound: 0, + unbound: 0, + ambiguous: 0, + unmatched: 0, + }; + for entity in &self.entities { + match entity.status { + BindingStatus::Bound { .. } => totals.bound += 1, + BindingStatus::Ambiguous(_) => { + totals.unbound += 1; + totals.ambiguous += 1; + } + BindingStatus::Unmatched(_) => { + totals.unbound += 1; + totals.unmatched += 1; + } + } + } + totals + } +} + +/// An ambiguous entity parked against a fallback master, with its unresolved +/// identity retained for later reallocation. +/// +/// Constructed only from an entity that did not bind, so rebinding something +/// that already matched is not a representable state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct FallbackBinding { + position: usize, + source_name: String, + fallback_name: String, + retained: Vec, + reason: UnboundReason, +} + +impl FallbackBinding { + /// Parks one unbound entity against a catalog-verified fallback master. + /// + /// Refuses a bound entity and refuses a fallback name that is not a current + /// catalog entry — a suspense ledger that does not exist is how one + /// engagement lost a batch. + pub fn assign( + entity: &EntityBinding, + catalog: &MasterCatalog, + fallback_name: &str, + ) -> Result { + let unresolved = entity + .unresolved() + .ok_or(MasterBindingError::FallbackNotInCatalog)?; + let fallback = catalog + .exact(fallback_name) + .ok_or(MasterBindingError::FallbackNotInCatalog)?; + Ok(Self { + position: entity.position, + source_name: entity.source_name.clone(), + fallback_name: fallback.to_string(), + retained: unresolved.unresolved_identity.clone(), + reason: unresolved.reason, + }) + } + + pub fn position(&self) -> usize { + self.position + } + + pub fn source_name(&self) -> &str { + &self.source_name + } + + pub fn fallback_name(&self) -> &str { + &self.fallback_name + } + + pub fn reason(&self) -> UnboundReason { + self.reason + } + + pub fn retained(&self) -> &[Identifier] { + &self.retained + } + + /// The identity to carry into a narration so the parked amount can be + /// reallocated without re-reading the source. Empty when the source name + /// carried no identifier at all — which is itself worth seeing. + pub fn retained_tag(&self) -> String { + self.retained + .iter() + .map(|identifier| { + let kind = match identifier.kind { + IdentifierKind::Numeric => "numeric", + IdentifierKind::Code => "code", + }; + format!("{kind}:{}", identifier.value) + }) + .collect::>() + .join(" ") + } +} + +/// One entity named by a source document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceEntity { + position: usize, + name: String, + key: String, + identifiers: Vec, +} + +impl SourceEntity { + /// Parses one source name at the boundary, extracting identifiers from it. + pub fn new(position: usize, name: &str) -> Result { + Self::with_identifier_hints(position, name, std::iter::empty::<&str>()) + } + + /// Parses one source name together with identifiers the document carries + /// outside the name — a statement's payment reference, a report's mobile + /// column. A hint that yields no identifier is refused rather than ignored. + pub fn with_identifier_hints<'a>( + position: usize, + name: &str, + hints: impl IntoIterator, + ) -> Result { + let name = validated_name(name)?; + let mut identifiers = extract_identifiers(&name); + for hint in hints { + let extracted = extract_identifiers(hint); + if extracted.is_empty() { + return Err(MasterBindingError::IdentifierHintUnusable); + } + identifiers.extend(extracted); + } + identifiers.sort(); + identifiers.dedup(); + identifiers.truncate(MAX_IDENTIFIERS_PER_NAME); + Ok(Self { + position, + key: comparison_key(&name), + name, + identifiers, + }) + } + + pub fn position(&self) -> usize { + self.position + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn identifiers(&self) -> &[Identifier] { + &self.identifiers + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CatalogEntry { + name: String, + key: String, + identifiers: Vec, + tokens: BTreeSet, +} + +/// One company's observed masters of one class, indexed for binding. +/// +/// Valid by construction: `bind` cannot fail because everything that could fail +/// was decided here. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MasterCatalog { + class: MasterClass, + entries: Vec, + by_name: BTreeMap, + by_key: BTreeMap>, + by_identifier: BTreeMap>, + by_token: BTreeMap>, + common_tokens: BTreeSet, +} + +impl MasterCatalog { + /// Parses the observed master names of one class. + /// + /// Names arrive in whatever order the book returned them; the index is + /// ordered, so a report does not depend on that order. + pub fn new(class: MasterClass, names: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let mut entries = Vec::new(); + let mut by_name = BTreeMap::new(); + for name in names { + if entries.len() >= MAX_CATALOG_ENTRIES { + return Err(MasterBindingError::CatalogTooLarge); + } + let name = validated_name(name.as_ref())?; + if by_name.contains_key(&name) { + return Err(MasterBindingError::CatalogDuplicateName); + } + by_name.insert(name.clone(), entries.len()); + let key = comparison_key(&name); + entries.push(CatalogEntry { + identifiers: extract_identifiers(&name), + tokens: tokens_of(&key), + key, + name, + }); + } + if entries.is_empty() { + return Err(MasterBindingError::CatalogEmpty); + } + + let mut by_key: BTreeMap> = BTreeMap::new(); + let mut by_identifier: BTreeMap> = BTreeMap::new(); + let mut by_token: BTreeMap> = BTreeMap::new(); + for (index, entry) in entries.iter().enumerate() { + by_key.entry(entry.key.clone()).or_default().push(index); + for identifier in &entry.identifiers { + by_identifier + .entry(identifier.clone()) + .or_default() + .push(index); + } + for token in &entry.tokens { + by_token.entry(token.clone()).or_default().push(index); + } + } + + // A token carried by a large share of the catalog says nothing about + // which master is meant. The threshold is measured from the catalog + // rather than a built-in word list, so it carries no language or + // domain assumption. + let common_tokens = if entries.len() >= COMMON_TOKEN_MIN_CATALOG { + let limit = entries.len() * COMMON_TOKEN_PERCENT / 100; + by_token + .iter() + .filter(|(_, holders)| holders.len() > limit) + .map(|(token, _)| token.clone()) + .collect() + } else { + BTreeSet::new() + }; + + Ok(Self { + class, + entries, + by_name, + by_key, + by_identifier, + by_token, + common_tokens, + }) + } + + pub fn class(&self) -> MasterClass { + self.class + } + + /// Masters in this catalog. Never zero: an empty catalog is refused at + /// construction, so there is no emptiness for a caller to test. + pub fn master_count(&self) -> usize { + self.entries.len() + } + + pub fn names(&self) -> impl Iterator { + self.entries.iter().map(|entry| entry.name.as_str()) + } + + /// The observed name, when it is byte-identical to a current entry. + pub fn exact(&self, name: &str) -> Option<&str> { + self.by_name + .get(name) + .map(|index| self.entries[*index].name.as_str()) + } +} + +/// Binds every source entity against the catalog. +/// +/// The entity bound is enforced here rather than left to callers: a source +/// document is untrusted input, and an unbounded variant would be a rule +/// someone has to remember. Everything else was already decided by the two +/// constructors, so this is the only way binding can fail. +pub fn bind( + catalog: &MasterCatalog, + entities: &[SourceEntity], +) -> Result { + if entities.len() > MAX_SOURCE_ENTITIES { + return Err(MasterBindingError::TooManySourceEntities); + } + Ok(BindingReport { + class: catalog.class, + entities: entities + .iter() + .map(|entity| bind_one(catalog, entity)) + .collect(), + }) +} + +fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { + let exact = catalog.by_name.get(&entity.name).copied(); + + // Rule one: the identifier is the key, the name is a hint. A name + // comparison on a pair that carries a decisive identifier is not merely + // weaker evidence, it is actively misleading. + let mut identifier_matches = BTreeSet::new(); + let mut identifier_conflict = false; + for identifier in &entity.identifiers { + if let Some(holders) = catalog.by_identifier.get(identifier) { + if holders.len() > 1 { + identifier_conflict = true; + } + identifier_matches.extend(holders.iter().copied()); + } + } + + // An identifier shared by two masters, and an entity whose identifiers + // reach two masters, are the same refusal: the operator has a naming + // collision to see, and neither case licenses a choice. + let status = if identifier_conflict || identifier_matches.len() > 1 { + unresolved_status( + catalog, + entity, + UnboundReason::IdentifierConflict, + exact, + &identifier_matches, + ) + } else if let Some(matched) = identifier_matches.iter().copied().next() { + // An identifier pointing at one master while the name exactly names + // another is a disagreement between two strong signals; it is shown, + // not silently decided in the identifier's favour. + if exact.is_some_and(|index| index != matched) { + unresolved_status( + catalog, + entity, + UnboundReason::IdentifierNameConflict, + exact, + &identifier_matches, + ) + } else { + BindingStatus::Bound { + catalog_name: catalog.entries[matched].name.clone(), + basis: BindingBasis::Identifier, + } + } + } else if let Some(index) = exact { + BindingStatus::Bound { + catalog_name: catalog.entries[index].name.clone(), + basis: BindingBasis::ExactName, + } + } else { + match catalog.by_key.get(&entity.key).map(Vec::as_slice) { + Some([index]) => BindingStatus::Bound { + catalog_name: catalog.entries[*index].name.clone(), + basis: BindingBasis::NormalizedName, + }, + Some(_) => unresolved_status( + catalog, + entity, + UnboundReason::NameAmbiguous, + exact, + &identifier_matches, + ), + None => { + let candidates = collect_candidates(catalog, entity, &identifier_matches); + let reason = if candidates.is_empty() { + UnboundReason::NoCandidate + } else { + UnboundReason::NearMiss + }; + unresolved_from(entity, reason, candidates) + } + } + }; + + EntityBinding { + position: entity.position, + source_name: entity.name.clone(), + status, + } +} + +fn unresolved_status( + catalog: &MasterCatalog, + entity: &SourceEntity, + reason: UnboundReason, + exact: Option, + identifier_matches: &BTreeSet, +) -> BindingStatus { + let mut candidates = collect_candidates(catalog, entity, identifier_matches); + if let Some(index) = exact { + let name = catalog.entries[index].name.as_str(); + if !candidates.iter().any(|(candidate, _)| candidate == name) { + candidates.push((name.to_string(), CandidateRule::NormalizedEqual)); + } + } + unresolved_from(entity, reason, candidates) +} + +fn unresolved_from( + entity: &SourceEntity, + reason: UnboundReason, + candidates: Vec<(String, CandidateRule)>, +) -> BindingStatus { + let mut ordered = candidates; + ordered.sort_by(|left, right| { + left.1 + .rank() + .cmp(&right.1.rank()) + .then_with(|| left.0.cmp(&right.0)) + }); + let candidate_count = ordered.len(); + let candidates_truncated = candidate_count > MAX_CANDIDATES_PER_ENTITY; + let candidates = ordered + .into_iter() + .take(MAX_CANDIDATES_PER_ENTITY) + .map(|(catalog_name, rule)| Candidate { catalog_name, rule }) + .collect(); + let unresolved = Unresolved { + reason, + unresolved_identity: entity.identifiers.clone(), + candidates, + candidate_count, + candidates_truncated, + }; + if matches!(reason, UnboundReason::NoCandidate) { + BindingStatus::Unmatched(unresolved) + } else { + BindingStatus::Ambiguous(unresolved) + } +} + +/// Produces every defensible master, each labelled with the rule that surfaced +/// it. The strongest rule wins where several apply. Nothing here ranks by +/// similarity, and nothing here chooses. +fn collect_candidates( + catalog: &MasterCatalog, + entity: &SourceEntity, + identifier_matches: &BTreeSet, +) -> Vec<(String, CandidateRule)> { + let mut best: BTreeMap = BTreeMap::new(); + let mut offer = |index: usize, rule: CandidateRule| { + best.entry(index) + .and_modify(|current| { + if rule.rank() < current.rank() { + *current = rule; + } + }) + .or_insert(rule); + }; + + for index in identifier_matches { + offer(*index, CandidateRule::SharedIdentifier); + } + if let Some(holders) = catalog.by_key.get(&entity.key) { + for index in holders { + offer(*index, CandidateRule::NormalizedEqual); + } + } + if entity.key.chars().count() >= MIN_PREFIX_KEY_CHARS { + // The key index is ordered, so both prefix directions are range or + // point lookups rather than a scan of the whole catalog per entity. + for (key, holders) in catalog.by_key.range(entity.key.clone()..) { + if !key.starts_with(&entity.key) { + break; + } + if key == &entity.key { + continue; + } + for index in holders { + offer(*index, CandidateRule::CatalogPrefix); + } + } + for split in MIN_PREFIX_KEY_CHARS..entity.key.len() { + if !entity.key.is_char_boundary(split) { + continue; + } + let prefix = &entity.key[..split]; + if prefix.chars().count() < MIN_PREFIX_KEY_CHARS { + continue; + } + if let Some(holders) = catalog.by_key.get(prefix) { + for index in holders { + offer(*index, CandidateRule::SourcePrefix); + } + } + } + } + for token in tokens_of(&entity.key) { + if catalog.common_tokens.contains(&token) { + continue; + } + if let Some(holders) = catalog.by_token.get(&token) { + for index in holders { + offer(*index, CandidateRule::SharedToken); + } + } + } + + best.into_iter() + .map(|(index, rule)| (catalog.entries[index].name.clone(), rule)) + .collect() +} + +fn validated_name(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(MasterBindingError::NameBlank); + } + if value.chars().any(char::is_control) { + return Err(MasterBindingError::NameUnsafe); + } + if value.chars().count() > MAX_NAME_CHARS { + return Err(MasterBindingError::NameTooLong); + } + Ok(value.to_string()) +} + +/// Folds the punctuation an operator happened to type: NFC-equivalent dash and +/// quote variants become ASCII, case is lowered, whitespace runs collapse. +/// Nothing else is folded — no stemming, no transliteration, no vowel removal. +fn comparison_key(value: &str) -> String { + value + .nfc() + .flat_map(|character| match character { + '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}' + | '\u{2212}' => vec!['-'], + '\u{2018}' | '\u{2019}' | '\u{201a}' | '\u{201b}' => vec!['\''], + '\u{201c}' | '\u{201d}' | '\u{201e}' | '\u{201f}' => vec!['"'], + other => other.to_lowercase().collect(), + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn tokens_of(key: &str) -> BTreeSet { + key.split(|character: char| !character.is_alphanumeric()) + .filter(|token| token.chars().count() >= MIN_TOKEN_CHARS) + .map(str::to_string) + .collect() +} + +/// Extracts every identifier a name carries, in canonical form. +/// +/// A numeric run may hold `-` and `/` internally, so a punctuated account +/// number and a plain one agree; it may not hold spaces, so separated digit +/// groups fail closed to a near-miss rather than fusing into a false +/// identifier. +fn extract_identifiers(value: &str) -> Vec { + let mut identifiers = BTreeSet::new(); + for run in value.split(|character: char| { + !(character.is_ascii_digit() || character == '-' || character == '/') + }) { + let digits = run.chars().filter(char::is_ascii_digit).collect::(); + if digits.len() >= MIN_NUMERIC_IDENTIFIER_DIGITS && !is_plausible_date(&digits) { + identifiers.insert(Identifier { + kind: IdentifierKind::Numeric, + value: digits, + }); + } + } + for token in value.split(char::is_whitespace) { + let canonical = token + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .map(|character| character.to_ascii_uppercase()) + .collect::(); + let digits = canonical.chars().filter(char::is_ascii_digit).count(); + let letters = canonical.chars().filter(char::is_ascii_alphabetic).count(); + if canonical.len() >= MIN_CODE_IDENTIFIER_CHARS + && digits >= MIN_CODE_IDENTIFIER_DIGITS + && letters >= 1 + { + identifiers.insert(Identifier { + kind: IdentifierKind::Code, + value: canonical, + }); + } + } + let mut identifiers = identifiers.into_iter().collect::>(); + identifiers.truncate(MAX_IDENTIFIERS_PER_NAME); + identifiers +} + +/// An eight-digit run that reads as a calendar date is a date. Excluding it +/// costs a near-miss on an account number that happens to look like one, and +/// prevents a period label binding two unrelated masters together. +fn is_plausible_date(digits: &str) -> bool { + if digits.len() != 8 { + return false; + } + let number = |range: std::ops::Range| digits[range].parse::().unwrap_or(0); + let (year, month, day) = (number(0..4), number(4..6), number(6..8)); + (1900..=2199).contains(&year) && (1..=12).contains(&month) && (1..=31).contains(&day) +} + +#[cfg(test)] +#[path = "master_binding_tests.rs"] +mod tests; 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 new file mode 100644 index 00000000..fbaedf54 --- /dev/null +++ b/src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs @@ -0,0 +1,507 @@ +//! Every name, code and number here is fabricated from a placeholder +//! alphabet — Greek-letter party names, `PH-` stock codes, and numbers drawn +//! from the `55500000xx` placeholder block. Nothing is edited down from an +//! observed book, and none of it is evidence about any Tally instance: these +//! tests establish the behaviour of the binding rules only. + +use super::*; + +fn ledgers(names: &[&str]) -> MasterCatalog { + MasterCatalog::new(MasterClass::Ledger, names).expect("fabricated catalog is valid") +} + +fn entity(name: &str) -> SourceEntity { + SourceEntity::new(1, name).expect("fabricated source name is valid") +} + +fn bound(catalog: &MasterCatalog, entities: &[SourceEntity]) -> BindingReport { + bind(catalog, entities).expect("fabricated entity list is within bounds") +} + +fn bind_one_name(catalog: &MasterCatalog, name: &str) -> EntityBinding { + bound(catalog, &[entity(name)]) + .entities() + .first() + .cloned() + .expect("one entity in, one binding out") +} + +fn candidate_names(binding: &EntityBinding) -> Vec<&str> { + binding + .unresolved() + .expect("binding did not resolve") + .candidates + .iter() + .map(|candidate| candidate.catalog_name.as_str()) + .collect() +} + +fn reason(binding: &EntityBinding) -> UnboundReason { + binding + .unresolved() + .expect("binding did not resolve") + .reason +} + +// --- inputs are valid by construction ------------------------------------- + +#[test] +fn an_unread_book_is_an_error_not_an_empty_report() { + // Binding against a book nobody read is the failure this contract exists + // to prevent, so it cannot be expressed as "everything is missing". + let empty: [&str; 0] = []; + assert_eq!( + MasterCatalog::new(MasterClass::Ledger, empty), + Err(MasterBindingError::CatalogEmpty) + ); +} + +#[test] +fn a_duplicate_master_name_refuses_the_catalog() { + assert_eq!( + MasterCatalog::new(MasterClass::Ledger, ["Alpha Traders", "Alpha Traders"]), + Err(MasterBindingError::CatalogDuplicateName) + ); +} + +#[test] +fn unusable_names_are_refused_at_the_boundary() { + assert_eq!( + MasterCatalog::new(MasterClass::Ledger, [" "]), + Err(MasterBindingError::NameBlank) + ); + assert_eq!( + MasterCatalog::new(MasterClass::Ledger, ["Alpha\u{7}Traders"]), + Err(MasterBindingError::NameUnsafe) + ); + let long = "A".repeat(MAX_NAME_CHARS + 1); + assert_eq!( + MasterCatalog::new(MasterClass::Ledger, [long.as_str()]), + Err(MasterBindingError::NameTooLong) + ); + assert_eq!(SourceEntity::new(0, ""), Err(MasterBindingError::NameBlank)); +} + +#[test] +fn an_identifier_hint_that_yields_nothing_is_refused_rather_than_ignored() { + assert_eq!( + SourceEntity::with_identifier_hints(0, "Alpha Traders", ["not-an-identifier"]), + Err(MasterBindingError::IdentifierHintUnusable) + ); + let entity = SourceEntity::with_identifier_hints(0, "Alpha Traders", ["5550000001"]) + .expect("a usable hint is accepted"); + assert_eq!( + entity.identifiers(), + [Identifier { + kind: IdentifierKind::Numeric, + value: "5550000001".to_string(), + }] + ); +} + +#[test] +fn the_source_entity_bound_is_enforced_where_a_document_is_unbounded() { + let catalog = ledgers(&["Alpha Traders"]); + let entities = (0..=MAX_SOURCE_ENTITIES) + .map(|position| SourceEntity::new(position, "Alpha Traders").expect("valid")) + .collect::>(); + assert_eq!( + bind(&catalog, &entities), + Err(MasterBindingError::TooManySourceEntities) + ); +} + +// --- what binds ------------------------------------------------------------ + +#[test] +fn an_exact_name_binds() { + let catalog = ledgers(&["Alpha Traders", "Beta Supply"]); + let binding = bind_one_name(&catalog, "Alpha Traders"); + assert_eq!( + binding.status, + BindingStatus::Bound { + catalog_name: "Alpha Traders".to_string(), + basis: BindingBasis::ExactName, + } + ); +} + +#[test] +fn case_whitespace_and_dash_style_do_not_defeat_a_bind() { + let catalog = ledgers(&["Alpha \u{2013} Traders", "Beta Supply"]); + let binding = bind_one_name(&catalog, " alpha - TRADERS "); + assert_eq!( + binding.status, + BindingStatus::Bound { + catalog_name: "Alpha \u{2013} Traders".to_string(), + basis: BindingBasis::NormalizedName, + } + ); +} + +#[test] +fn an_embedded_identifier_beats_three_wrong_name_candidates() { + // The engagement case, fabricated: the source names the party one way, the + // ledger another, and the only thing that agrees is the number the + // operator buried in the ledger name. Name matching offers three wrong + // people; the identifier decides. + let catalog = ledgers(&[ + "GAMMA (5550000001)", + "GAMMA ALPHA", + "GAMMA BETA", + "GAMMA DELTA", + ]); + let source = + SourceEntity::with_identifier_hints(3, "GAMMA. EPSILON", ["5550000001"]).expect("valid"); + let report = bound(&catalog, &[source]); + assert_eq!( + report.entities()[0].status, + BindingStatus::Bound { + catalog_name: "GAMMA (5550000001)".to_string(), + basis: BindingBasis::Identifier, + } + ); +} + +#[test] +fn an_identifier_inside_both_names_binds_without_a_hint() { + let catalog = ledgers(&["GAMMA (5550000001)", "GAMMA ALPHA"]); + let binding = bind_one_name(&catalog, "5550000001 GAMMA EPSILON"); + assert_eq!(binding.bound_name(), Some("GAMMA (5550000001)")); +} + +#[test] +fn a_punctuated_stock_code_binds_to_its_unpunctuated_form() { + let catalog = MasterCatalog::new( + MasterClass::StockItem, + ["PH-01A-B00", "PH-02A-B00", "Labour Placeholder"], + ) + .expect("valid"); + let binding = bound(&catalog, &[entity("PH01AB00")]) + .entities() + .first() + .cloned() + .expect("one binding"); + assert_eq!( + binding.status, + BindingStatus::Bound { + catalog_name: "PH-01A-B00".to_string(), + basis: BindingBasis::Identifier, + } + ); +} + +// --- what refuses to bind -------------------------------------------------- + +#[test] +fn one_identifier_carried_by_two_masters_is_ambiguous_never_a_bind() { + let catalog = ledgers(&["ALPHA (5550000001)", "BETA (5550000001)", "GAMMA Supply"]); + let binding = bind_one_name(&catalog, "PARTY 5550000001"); + assert_eq!(reason(&binding), UnboundReason::IdentifierConflict); + assert_eq!( + candidate_names(&binding), + ["ALPHA (5550000001)", "BETA (5550000001)"] + ); +} + +#[test] +fn an_identifier_and_an_exact_name_pointing_apart_is_shown_not_decided() { + let catalog = ledgers(&["ALPHA (5550000001)", "BETA Supply"]); + let source = + SourceEntity::with_identifier_hints(0, "BETA Supply", ["5550000001"]).expect("valid"); + let report = bound(&catalog, &[source]); + let binding = &report.entities()[0]; + assert_eq!(reason(binding), UnboundReason::IdentifierNameConflict); + assert!(candidate_names(binding).contains(&"ALPHA (5550000001)")); + assert!(candidate_names(binding).contains(&"BETA Supply")); +} + +#[test] +fn near_duplicate_masters_produce_candidates_and_choose_none() { + // Three masters differing by one character and word order. The operator + // decides; the module only shows the field. + let catalog = ledgers(&["ALPHA SALE", "ALPHA SALES", "SALES - ALPHA", "Beta Supply"]); + let binding = bind_one_name(&catalog, "ALPHA"); + assert_eq!(reason(&binding), UnboundReason::NearMiss); + assert_eq!( + candidate_names(&binding), + ["ALPHA SALE", "ALPHA SALES", "SALES - ALPHA"] + ); + let unresolved = binding.unresolved().expect("unbound"); + assert_eq!( + unresolved + .candidates + .iter() + .map(|candidate| candidate.rule) + .collect::>(), + [ + CandidateRule::CatalogPrefix, + CandidateRule::CatalogPrefix, + CandidateRule::SharedToken + ] + ); + assert_eq!(unresolved.candidate_count, 3); + assert!(!unresolved.candidates_truncated); +} + +#[test] +fn a_single_candidate_still_does_not_bind() { + // Four near-misses of ledgers that already existed rejected 61 vouchers. + // Uniqueness of a guess is not evidence. + let catalog = ledgers(&["ALPHA TRADING COMPANY", "Beta Supply"]); + let binding = bind_one_name(&catalog, "ALPHA TRADING COMP"); + assert_eq!(reason(&binding), UnboundReason::NearMiss); + assert_eq!(candidate_names(&binding), ["ALPHA TRADING COMPANY"]); + assert_eq!(binding.bound_name(), None); +} + +#[test] +fn a_truncated_source_name_surfaces_the_longer_master() { + let catalog = ledgers(&["DELTA WHOLESALE PLACEHOLDER", "Beta Supply"]); + let binding = bind_one_name(&catalog, "DELTA WHOLESALE PL"); + assert_eq!( + binding + .unresolved() + .expect("unbound") + .candidates + .first() + .map(|candidate| candidate.rule), + Some(CandidateRule::CatalogPrefix) + ); +} + +#[test] +fn a_source_name_extending_a_master_surfaces_the_shorter_master() { + let catalog = ledgers(&["DELTA WHOLESALE", "Beta Supply"]); + let binding = bind_one_name(&catalog, "DELTA WHOLESALE PLACEHOLDER BRANCH"); + let unresolved = binding.unresolved().expect("unbound"); + assert!(unresolved + .candidates + .iter() + .any(|candidate| candidate.rule == CandidateRule::SourcePrefix + && candidate.catalog_name == "DELTA WHOLESALE")); +} + +#[test] +fn nothing_defensible_is_unmatched_with_no_candidate() { + let catalog = ledgers(&["Alpha Traders", "Beta Supply"]); + let binding = bind_one_name(&catalog, "Zeta Placeholder"); + assert!(matches!(binding.status, BindingStatus::Unmatched(_))); + assert_eq!(reason(&binding), UnboundReason::NoCandidate); + assert!(candidate_names(&binding).is_empty()); + assert_eq!( + reason(&binding).safe_reason_code(), + "master_binding_no_candidate" + ); +} + +// --- the identifier rules fail closed -------------------------------------- + +#[test] +fn a_date_shaped_run_is_not_an_identifier() { + // Two unrelated period-labelled masters must not fuse on their period. + let catalog = ledgers(&["ALPHA 2026-09-10", "BETA 2026-09-10", "Gamma Supply"]); + let binding = bind_one_name(&catalog, "DELTA 2026-09-10"); + assert!(binding.unresolved().is_some()); + assert!(binding + .unresolved() + .expect("unbound") + .unresolved_identity + .is_empty()); +} + +#[test] +fn a_short_digit_run_is_not_an_identifier() { + // A masked last-four cannot bind two accounts that share four digits. + let catalog = ledgers(&["ALPHA BANK CA 2129", "BETA BANK CA 2129"]); + let binding = bind_one_name(&catalog, "GAMMA BANK CA 2129"); + assert!(binding + .unresolved() + .expect("unbound") + .unresolved_identity + .is_empty()); +} + +#[test] +fn separated_digit_groups_do_not_fuse_into_an_identifier() { + let entity = entity("ALPHA 5550 0000 01"); + assert!(entity.identifiers().is_empty()); +} + +// --- candidate discipline -------------------------------------------------- + +#[test] +fn a_catalog_wide_token_stops_discriminating() { + let mut names = (0..40) + .map(|index| format!("PLACEHOLDER UNIT {index:02}")) + .collect::>(); + names.push("ALPHA PLACEHOLDER TRADERS".to_string()); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + // "placeholder" is carried by every entry, so it may not pull all 41 in. + let binding = bind_one_name(&catalog, "PLACEHOLDER ZETA"); + assert!(binding.unresolved().expect("unbound").candidate_count <= 1); +} + +#[test] +fn candidates_are_capped_with_the_true_count_retained() { + let names = (0..MAX_CANDIDATES_PER_ENTITY + 5) + .map(|index| format!("ALPHAGROUP UNIT {index:02}")) + .collect::>(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let binding = bind_one_name(&catalog, "ALPHAGROUP"); + let unresolved = binding.unresolved().expect("unbound"); + assert_eq!(unresolved.candidates.len(), MAX_CANDIDATES_PER_ENTITY); + assert_eq!(unresolved.candidate_count, MAX_CANDIDATES_PER_ENTITY + 5); + assert!(unresolved.candidates_truncated); +} + +#[test] +fn candidate_order_is_rule_then_name_and_never_a_ranking() { + let catalog = ledgers(&[ + "ALPHA (5550000002)", + "ALPHA WHOLESALE", + "ZETA ALPHA STORE", + "ALPHA (5550000003)", + ]); + let source = SourceEntity::with_identifier_hints(0, "ALPHA", ["5550000002", "5550000003"]) + .expect("valid"); + let report = bound(&catalog, &[source]); + let unresolved = report.entities()[0].unresolved().expect("unbound"); + assert_eq!( + unresolved + .candidates + .iter() + .map(|candidate| (candidate.catalog_name.as_str(), candidate.rule)) + .collect::>(), + [ + ("ALPHA (5550000002)", CandidateRule::SharedIdentifier), + ("ALPHA (5550000003)", CandidateRule::SharedIdentifier), + ("ALPHA WHOLESALE", CandidateRule::CatalogPrefix), + ("ZETA ALPHA STORE", CandidateRule::SharedToken), + ] + ); +} + +#[test] +fn the_report_does_not_depend_on_the_order_the_book_returned() { + let forward = ledgers(&["ALPHA SALE", "ALPHA SALES", "SALES - ALPHA", "Beta Supply"]); + let reversed = ledgers(&["Beta Supply", "SALES - ALPHA", "ALPHA SALES", "ALPHA SALE"]); + let entities = [entity("ALPHA"), entity("Beta Supply")]; + assert_eq!( + bound(&forward, &entities).entities(), + bound(&reversed, &entities).entities() + ); +} + +// --- the unbound list is the product --------------------------------------- + +#[test] +fn totals_reconcile_the_run() { + let catalog = ledgers(&["Alpha Traders", "ALPHA SALE", "ALPHA SALES"]); + let entities = [ + entity("Alpha Traders"), + entity("ALPHA"), + entity("Zeta Placeholder"), + ]; + let report = bound(&catalog, &entities); + let totals = report.totals(); + assert_eq!(totals.requested, 3); + assert_eq!(totals.bound, 1); + assert_eq!(totals.ambiguous, 1); + assert_eq!(totals.unmatched, 1); + assert_eq!(totals.requested, totals.bound + totals.unbound); + assert_eq!(totals.unbound, totals.ambiguous + totals.unmatched); + assert_eq!(report.bound().count(), totals.bound); + assert_eq!(report.unbound().count(), totals.unbound); + assert_eq!(report.class(), MasterClass::Ledger); +} + +#[test] +fn an_unbound_entry_retains_the_identity_that_will_reallocate_it() { + let catalog = ledgers(&["ALPHA (5550000001)", "BETA (5550000001)"]); + let binding = bind_one_name(&catalog, "PARTY 5550000001"); + assert_eq!( + binding.unresolved().expect("unbound").unresolved_identity, + [Identifier { + kind: IdentifierKind::Numeric, + value: "5550000001".to_string(), + }] + ); +} + +// --- the fallback is constructed, never inferred --------------------------- + +#[test] +fn an_ambiguous_entity_parks_against_a_verified_fallback() { + let catalog = ledgers(&[ + "ALPHA (5550000001)", + "BETA (5550000001)", + "Suspense Placeholder", + ]); + let binding = bind_one_name(&catalog, "PARTY 5550000001"); + let fallback = FallbackBinding::assign(&binding, &catalog, "Suspense Placeholder") + .expect("an unbound entity may be parked"); + assert_eq!(fallback.fallback_name(), "Suspense Placeholder"); + assert_eq!(fallback.source_name(), "PARTY 5550000001"); + assert_eq!(fallback.reason(), UnboundReason::IdentifierConflict); + assert_eq!(fallback.retained_tag(), "numeric:5550000001"); +} + +#[test] +fn a_bound_entity_cannot_be_parked() { + let catalog = ledgers(&["Alpha Traders", "Suspense Placeholder"]); + let binding = bind_one_name(&catalog, "Alpha Traders"); + assert_eq!( + FallbackBinding::assign(&binding, &catalog, "Suspense Placeholder"), + Err(MasterBindingError::FallbackNotInCatalog) + ); +} + +#[test] +fn a_fallback_master_that_does_not_exist_is_refused() { + // A suspense ledger that was never created is how one batch was lost. + let catalog = ledgers(&["Alpha Traders", "Beta Supply"]); + let binding = bind_one_name(&catalog, "Zeta Placeholder"); + assert_eq!( + FallbackBinding::assign(&binding, &catalog, "Suspense Placeholder"), + Err(MasterBindingError::FallbackNotInCatalog) + ); +} + +// --- the vocabulary is stable ---------------------------------------------- + +#[test] +fn reason_and_error_codes_are_stable_and_safe() { + assert_eq!( + UnboundReason::IdentifierConflict.safe_reason_code(), + "master_binding_identifier_conflict" + ); + assert_eq!( + UnboundReason::IdentifierNameConflict.safe_reason_code(), + "master_binding_identifier_name_conflict" + ); + assert_eq!( + UnboundReason::NameAmbiguous.safe_reason_code(), + "master_binding_name_ambiguous" + ); + assert_eq!( + UnboundReason::NearMiss.safe_reason_code(), + "master_binding_near_miss" + ); + assert_eq!( + MasterBindingError::CatalogEmpty.safe_reason_code(), + "master_catalog_empty" + ); +} + +#[test] +fn a_bound_status_serializes_without_a_score_field() { + let catalog = ledgers(&["Alpha Traders"]); + let binding = bind_one_name(&catalog, "Alpha Traders"); + let json = serde_json::to_value(&binding).expect("serializable"); + assert_eq!(json["status"], "bound"); + assert_eq!(json["catalog_name"], "Alpha Traders"); + assert_eq!(json["basis"], "exact_name"); + assert!(json.get("score").is_none()); + assert!(json.get("confidence").is_none()); +} diff --git a/src-tauri/src/agent_catalog.rs b/src-tauri/src/agent_catalog.rs index cbf12a1b..c3e6935d 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" => ( - "Validate 1–100 nonblank ledger names (at most 1024 characters each). Near-miss suggestions are bounded to 25 names and 8192 UTF-8 bytes per requested name, with total count and truncation reported.", + "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 total count and truncation reported. 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 7d4af8c7..f6eb39c7 100644 --- a/src-tauri/src/agent_import.rs +++ b/src-tauri/src/agent_import.rs @@ -8,6 +8,9 @@ use crate::tally::standard_ledger_catalog::{ admit_standard_ledger_catalog_request, parse_standard_ledger_catalog_response, render_standard_ledger_catalog_request, }; +use bridge_tally_core::master_binding::{ + self, BindingBasis, BindingStatus, EntityBinding, MasterCatalog, MasterClass, SourceEntity, +}; use bridge_tally_core::ExactDecimal; use bridge_tally_protocol::outstandings_shared::DateBoundaryProfile; use chrono::{SecondsFormat, Utc}; @@ -39,7 +42,6 @@ mod persistence; #[path = "agent_import_post.rs"] mod post; use std::path::{Path, PathBuf}; -use unicode_normalization::UnicodeNormalization; use uuid::Uuid; struct ImportProfileObservation { @@ -279,10 +281,11 @@ impl Server { .read_ledger_catalogue(&identity, &company.name) .await .map_err(|failure| failure.with_prior_evidence(identity_evidence.clone()))?; - let report = ledgers - .into_iter() - .map(|wanted| master_match(wanted, &catalogue)) - .collect::>(); + let report = master_report( + &ledgers.into_iter().map(str::to_string).collect::>(), + &catalogue, + ) + .map_err(|code| ToolFailure::from(code).with_prior_evidence(identity_evidence.clone()))?; let hash = sha256_json(&catalogue); Ok(ToolOutcome { payload: json!({"company": company_json(&company, std::slice::from_ref(&company)), "result": {"masters": report, "catalogue_evidence_sha256": hash}}), @@ -369,7 +372,7 @@ impl Server { let (catalogue, catalogue_evidence) = self.read_ledger_catalogue(&identity, &company.name).await?; accumulated = combine_evidence(accumulated.clone(), catalogue_evidence.clone()); - let report = masters_for_payload(&payload, &catalogue); + let report = masters_for_payload(&payload, &catalogue)?; if report.iter().any(|value| value["match_state"] != "exact") { return Ok(ToolOutcome { payload: json!({"company": company_json(&company, std::slice::from_ref(&company)), "result": { @@ -1109,11 +1112,11 @@ fn totals(vouchers: &[ImportVoucher]) -> Result<(ExactDecimal, ExactDecimal), St Ok((debit, credit)) } -fn masters_for_payload(payload: &ImportPayload, catalogue: &[String]) -> Vec { - requested_ledger_names(payload) - .into_iter() - .map(|name| master_match(&name, catalogue)) - .collect() +fn masters_for_payload( + payload: &ImportPayload, + catalogue: &[String], +) -> Result, String> { + master_report(&requested_ledger_names(payload), catalogue) } fn requested_ledger_names(payload: &ImportPayload) -> Vec { @@ -1127,53 +1130,93 @@ fn requested_ledger_names(payload: &ImportPayload) -> Vec { .collect() } -fn master_match(wanted: &str, catalogue: &[String]) -> Value { - if catalogue.iter().any(|name| name == wanted) { - return json!({"requested": party_name(wanted), "match_state":"exact", "exact_live_spelling":party_name(wanted)}); - } - let key = master_key(wanted); - let candidates = catalogue +/// Bounds the candidate names one unbound entity may copy into a tool result. +/// The binding contract bounds the candidate *count*; this bounds their bytes, +/// which is an egress concern rather than a matching one. +const MAX_CANDIDATE_RESULT_BYTES: usize = 8_192; + +/// Binds requested ledger names against the observed catalogue. +/// +/// The rules live in `bridge_tally_core::master_binding` so this tool and the +/// desktop preparation screen cannot drift apart; see +/// `docs/adr/0016-master-binding-authority.md`. This function only renders the +/// report, and it never promotes a candidate into a spelling. +fn master_report(requested: &[String], catalogue: &[String]) -> Result, String> { + let catalog = MasterCatalog::new(MasterClass::Ledger, catalogue) + .map_err(|error| error.safe_reason_code().to_string())?; + let entities = requested .iter() - .filter(|name| { - let candidate = master_key(name); - candidate == key || candidate.starts_with(&key) || key.starts_with(&candidate) - }) - .collect::>(); - let candidate_count = candidates.len(); - if candidate_count == 0 { - json!({"requested":party_name(wanted),"match_state":"missing"}) - } else { - let mut bytes = 0_usize; - let candidates = candidates - .into_iter() - .take(25) - .take_while(|name| { - bytes = bytes.saturating_add(name.len()); - bytes <= 8192 + .enumerate() + .map(|(position, name)| SourceEntity::new(position, name)) + .collect::, _>>() + .map_err(|error| error.safe_reason_code().to_string())?; + let report = master_binding::bind(&catalog, &entities) + .map_err(|error| error.safe_reason_code().to_string())?; + Ok(report.entities().iter().map(master_match_json).collect()) +} + +fn master_match_json(binding: &EntityBinding) -> Value { + let requested = party_name(binding.source_name.clone()); + match &binding.status { + BindingStatus::Bound { + catalog_name, + basis, + } => { + // Only byte-exact equality may be reported as `exact`: the import + // file carries the name verbatim, and build_import_xml admits + // nothing else. + let match_state = match basis { + BindingBasis::ExactName => "exact", + BindingBasis::NormalizedName => "normalized", + BindingBasis::Identifier => "identifier", + }; + json!({ + "requested": requested, + "match_state": match_state, + "exact_live_spelling": party_name(catalog_name.clone()), }) - .map(|name| party_name(name.clone())) - .collect::>(); - json!({"requested":party_name(wanted),"match_state":"near_miss", - "exact_live_spelling":candidates.first(),"candidate_count":candidate_count, - "candidates_truncated":candidates.len() < candidate_count,"candidates":candidates}) + } + BindingStatus::Ambiguous(unresolved) | BindingStatus::Unmatched(unresolved) => { + let mut bytes = 0_usize; + let candidates = unresolved + .candidates + .iter() + .take_while(|candidate| { + bytes = bytes.saturating_add(candidate.catalog_name.len()); + bytes <= MAX_CANDIDATE_RESULT_BYTES + }) + .map(|candidate| { + json!({ + "name": party_name(candidate.catalog_name.clone()), + "rule": candidate.rule, + }) + }) + .collect::>(); + // No `exact_live_spelling`. Naming one candidate as the live + // spelling is the auto-resolution that rejected a batch once. + json!({ + "requested": requested, + "match_state": match binding.status { + BindingStatus::Unmatched(_) => "missing", + _ => "near_miss", + }, + "reason": unresolved.reason.safe_reason_code(), + "candidate_count": unresolved.candidate_count, + "candidates_truncated": candidates.len() < unresolved.candidate_count, + "candidates": candidates, + "unresolved_identity": unresolved + .unresolved_identity + .iter() + .map(|identifier| json!({ + "kind": identifier.kind, + "value": party_name(identifier.value.clone()), + })) + .collect::>(), + }) + } } } -fn master_key(value: &str) -> String { - value - .nfc() - .flat_map(|character| match character { - '–' | '—' | '−' | '‐' | '‑' => "-".chars().collect::>(), - '‘' | '’' | '‚' | '‛' => "'".chars().collect(), - '“' | '”' | '„' | '‟' => "\"".chars().collect(), - other => other.to_lowercase().collect(), - }) - .collect::() - .split_whitespace() - .collect::>() - .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_post.rs b/src-tauri/src/agent_import_post.rs index c8d093a5..58b0a0c1 100644 --- a/src-tauri/src/agent_import_post.rs +++ b/src-tauri/src/agent_import_post.rs @@ -146,7 +146,7 @@ impl Server { .read_import_ledger_catalogue(&identity, &company.name) .await?; accumulated = combine_evidence(accumulated.clone(), evidence); - if masters_for_payload(&payload, &catalogue) + if masters_for_payload(&payload, &catalogue)? .iter() .any(|item| item["match_state"] != "exact") { diff --git a/src-tauri/src/agent_import_tests.rs b/src-tauri/src/agent_import_tests.rs index 977b310e..66021e83 100644 --- a/src-tauri/src/agent_import_tests.rs +++ b/src-tauri/src/agent_import_tests.rs @@ -275,29 +275,39 @@ fn schema_balance_matcher_rendering_and_ledger_append_are_fail_closed() { validate_payload(&unbalanced), Err("voucher_not_balanced".to_string()) ); + // Case, whitespace style, dash style and quote style name the same live + // ledger, so they bind and report its exact spelling. Only byte equality + // is `exact`, which is what build_import_xml admits. + for wanted in ["bank ", "bank", "BANK"] { + let matched = one_master_match(wanted, &["Bank"]); + assert_eq!(matched["match_state"], "normalized"); + assert_eq!( + matched["exact_live_spelling"][super::super::PARTY_NAME_MARKER], + "Bank" + ); + } assert_eq!( - master_match("bank ", &["Bank".to_string()])["match_state"], - "near_miss" - ); - assert_eq!( - master_match("bank", &["Bank".to_string()])["match_state"], - "near_miss" - ); - assert_eq!( - master_match("A\u{a0}B", &["A B".to_string()])["match_state"], - "near_miss" + one_master_match("A\u{a0}B", &["A B"])["match_state"], + "normalized" ); assert_eq!( - master_match("Fees-Admin", &["Fees–Admin".to_string()])["match_state"], - "near_miss" + one_master_match("Fees-Admin", &["Fees–Admin"])["match_state"], + "normalized" ); assert_eq!( - master_match("Bob's", &["Bob’s".to_string()])["match_state"], - "near_miss" + one_master_match("Bob's", &["Bob’s"])["match_state"], + "normalized" ); + assert_eq!(one_master_match("Bank", &["Bank"])["match_state"], "exact"); + // A shorter name that a live ledger extends is a near-miss with one + // candidate, and one candidate is still not a decision. + let near = one_master_match("Bank", &["Bank Charges"]); + assert_eq!(near["match_state"], "near_miss"); + assert_eq!(near["reason"], "master_binding_near_miss"); + assert!(near.get("exact_live_spelling").is_none()); assert_eq!( - master_match("Bank", &["Bank Charges".to_string()])["match_state"], - "near_miss" + near["candidates"][0]["name"][super::super::PARTY_NAME_MARKER], + "Bank Charges" ); let xml = render_import_xml("Book & Co", &input.vouchers, "batch-render"); assert!(xml.starts_with("")); @@ -1478,22 +1488,36 @@ fn native_captured_import_readback_keeps_direct_amounts_and_padded_identifiers() } } +/// Binds one name against a fabricated catalogue and returns its rendered row. +fn one_master_match(wanted: &str, catalogue: &[&str]) -> Value { + let catalogue = catalogue + .iter() + .map(|name| (*name).to_string()) + .collect::>(); + master_report(&[wanted.to_string()], &catalogue) + .expect("fabricated catalogue binds") + .remove(0) +} + #[test] fn master_match_bounds_suggestions_before_copying_names_and_preserves_ambiguity() { let catalogue = (0..100) .map(|index| format!("Ledger {index:03}")) .collect::>(); - let matched = master_match("L", &catalogue); + let borrowed = catalogue.iter().map(String::as_str).collect::>(); + let matched = one_master_match("Ledger", &borrowed); assert_eq!(matched["match_state"], "near_miss"); assert_eq!(matched["candidate_count"], 100); assert_eq!(matched["candidates_truncated"], true); assert_eq!(matched["candidates"].as_array().unwrap().len(), 25); assert_eq!( - master_match("Ledger 099", &catalogue)["match_state"], + one_master_match("Ledger 099", &borrowed)["match_state"], "exact" ); + // A single pathological live name is bounded by bytes before it is copied + // into a result, and its true count is still reported. let huge = format!("Large{}", "x".repeat(8192)); - let limited = master_match("L", std::slice::from_ref(&huge)); + let limited = one_master_match("Large", &[huge.as_str()]); assert_eq!(limited["match_state"], "near_miss"); assert_eq!(limited["candidate_count"], 1); assert_eq!(limited["candidates_truncated"], true); @@ -1501,6 +1525,57 @@ fn master_match_bounds_suggestions_before_copying_names_and_preserves_ambiguity( assert!(!limited.to_string().contains(&huge)); } +#[test] +fn a_catalogue_that_was_never_read_refuses_instead_of_reporting_everything_missing() { + // "Nobody read the ledger list out of Tally first" is the recorded cause + // of the one failed engagement, so an empty catalogue must not look like + // an answer. P5: nothing-found and request-failed stay distinguishable. + assert_eq!( + master_report(&["Bank".to_string()], &[]), + Err("master_catalog_empty".to_string()) + ); +} + +#[test] +fn an_embedded_identifier_decides_where_the_name_offers_wrong_candidates() { + // Fabricated from a placeholder alphabet: the live ledger carries a number + // the operator typed into its name, and the requested name matches no live + // spelling. The number is the key; the name is a hint. + let matched = one_master_match( + "GAMMA. EPSILON 5550000001", + &["GAMMA (5550000001)", "GAMMA ALPHA", "GAMMA BETA"], + ); + assert_eq!(matched["match_state"], "identifier"); + assert_eq!( + matched["exact_live_spelling"][super::super::PARTY_NAME_MARKER], + "GAMMA (5550000001)" + ); +} + +#[test] +fn a_near_miss_never_names_a_live_spelling_and_retains_its_identity() { + let matched = one_master_match( + "PARTY 5550000001", + &["ALPHA (5550000001)", "BETA (5550000001)"], + ); + assert_eq!(matched["match_state"], "near_miss"); + assert_eq!(matched["reason"], "master_binding_identifier_conflict"); + assert!(matched.get("exact_live_spelling").is_none()); + assert_eq!(matched["candidate_count"], 2); + assert_eq!( + matched["unresolved_identity"][0]["value"][super::super::PARTY_NAME_MARKER], + "5550000001" + ); +} + +#[test] +fn nothing_defensible_is_reported_missing_with_no_candidate() { + let matched = one_master_match("Zeta Placeholder", &["Bank", "Cash"]); + assert_eq!(matched["match_state"], "missing"); + assert_eq!(matched["reason"], "master_binding_no_candidate"); + assert!(matched["candidates"].as_array().unwrap().is_empty()); +} + #[tokio::test] async fn import_bounds_distinct_ledger_names_before_tally_without_reducing_voucher_limit() { let mut repeated = payload(); diff --git a/src-tauri/src/source_draft/catalog.rs b/src-tauri/src/source_draft/catalog.rs index fbae0a5a..7505924e 100644 --- a/src-tauri/src/source_draft/catalog.rs +++ b/src-tauri/src/source_draft/catalog.rs @@ -8,6 +8,9 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use bridge_tally_core::master_binding::{ + self, BindingBasis, BindingStatus, MasterCatalog, MasterClass, SourceEntity, +}; use bridge_tally_protocol::{StandardLedgerCatalog, StandardLedgerCatalogBinding}; use crate::{ @@ -51,9 +54,31 @@ pub(crate) struct SourceDraftCatalogTargets { pub(crate) capture_id: String, pub(crate) source_sha256: String, pub(crate) targets: Vec, + pub(crate) bindings: Vec, pub(crate) evidence: SourceDraftCatalogEvidence, } +/// One source entry's deterministic binding against the capture, so an +/// operator sees the few relevant ledgers rather than the whole catalog. +/// +/// This narrows a list and grants nothing. `bound_target` names a live ledger +/// only where the rules in `bridge_tally_core::master_binding` decided it +/// outright; a near-miss carries candidates and no target. Applying any of +/// them still goes through the unchanged apply path, which rereads the catalog +/// and proves the selection is current — matching text remains never a +/// selected or approved target. +#[derive(Debug, Serialize)] +pub(crate) struct SourceDraftCatalogBinding { + pub(crate) row_position: usize, + pub(crate) entry_position: usize, + pub(crate) bound_target: Option, + pub(crate) bound_basis: Option, + pub(crate) unbound_reason: Option<&'static str>, + pub(crate) candidates: Vec, + pub(crate) candidate_count: usize, + pub(crate) candidates_truncated: bool, +} + #[derive(Debug, Serialize)] pub(crate) struct SourceDraftCatalogEvidence { pub(crate) request_sha256: String, @@ -100,6 +125,71 @@ pub(super) struct CatalogApplySnapshot { pub(super) catalog: StandardLedgerCatalog, } +/// 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. +fn source_entry_bindings( + source: &crate::source_draft_xml::ParsedSource, + targets: &[String], +) -> Vec { + let Ok(catalog) = MasterCatalog::new(MasterClass::Ledger, targets) else { + return Vec::new(); + }; + let mut located = Vec::new(); + let mut entities = Vec::new(); + for voucher in &source.vouchers { + for entry in &voucher.entries { + let Ok(entity) = SourceEntity::new(entities.len(), &entry.ledger) else { + continue; + }; + located.push((voucher.position, entry.position)); + entities.push(entity); + } + } + let Ok(report) = master_binding::bind(&catalog, &entities) else { + return Vec::new(); + }; + report + .entities() + .iter() + .zip(located) + .map( + |(binding, (row_position, entry_position))| match &binding.status { + BindingStatus::Bound { + catalog_name, + basis, + } => SourceDraftCatalogBinding { + row_position, + entry_position, + bound_target: Some(catalog_name.clone()), + bound_basis: Some(*basis), + unbound_reason: None, + candidates: Vec::new(), + candidate_count: 0, + candidates_truncated: false, + }, + BindingStatus::Ambiguous(unresolved) | BindingStatus::Unmatched(unresolved) => { + SourceDraftCatalogBinding { + row_position, + entry_position, + bound_target: None, + bound_basis: None, + unbound_reason: Some(unresolved.reason.safe_reason_code()), + candidates: unresolved + .candidates + .iter() + .map(|candidate| candidate.catalog_name.clone()) + .collect(), + candidate_count: unresolved.candidate_count, + candidates_truncated: unresolved.candidates_truncated, + } + } + }, + ) + .collect() +} + pub(super) fn require_current_catalog_binding( binding: &StandardLedgerCatalogBinding, fresh_body: &str, @@ -244,6 +334,7 @@ impl SourceDraftStore { return Err(error("source_draft_catalogue_invalidated")); } let targets = read.catalog.names().map(str::to_owned).collect::>(); + let bindings = source_entry_bindings(¤t.source, &targets); let capture = CatalogCapture { id: Uuid::new_v4(), draft_id: current.id, @@ -258,6 +349,7 @@ impl SourceDraftStore { capture_id: capture.id.to_string(), source_sha256: capture.source_sha256.clone(), targets, + bindings, evidence: SourceDraftCatalogEvidence { request_sha256: read.request_sha256, response_sha256: read.response_sha256, @@ -427,6 +519,65 @@ mod tests { Fixture, ProductStatus, ResponseFraming, ScenarioPlan, SequenceSimulator, WireEncoding, }; + /// Fabricated from a placeholder alphabet; nothing here is edited down + /// from an observed book. + fn fabricated_source() -> crate::source_draft_xml::ParsedSource { + parse_source_xml( + concat!( + "", + "20260901", + "alpha traders1", + "GAMMA. EPSILON 5550000001-1", + "Zeta Placeholder0", + "" + ) + .as_bytes(), + "source.xml".into(), + ) + .expect("fabricated source parses") + } + + #[test] + fn a_capture_narrows_each_source_entry_without_deciding_a_near_miss() { + let targets = [ + "Alpha Traders".to_string(), + "GAMMA (5550000001)".to_string(), + "GAMMA ALPHA".to_string(), + "Beta Supply".to_string(), + ]; + let bindings = source_entry_bindings(&fabricated_source(), &targets); + assert_eq!(bindings.len(), 3); + + // Case alone does not defeat a bind, and the live spelling is named. + 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)); + + // The number the operator buried in the ledger name decides where the + // name offers a wrong candidate. + assert_eq!( + bindings[1].bound_target.as_deref(), + Some("GAMMA (5550000001)") + ); + assert_eq!(bindings[1].bound_basis, Some(BindingBasis::Identifier)); + + // Nothing defensible stays unbound with no target of any kind. + assert!(bindings[2].bound_target.is_none()); + assert_eq!( + bindings[2].unbound_reason, + Some("master_binding_no_candidate") + ); + assert!(bindings[2].candidates.is_empty()); + } + + #[test] + fn narrowing_is_advisory_and_never_fails_a_completed_capture() { + // An unusable capture narrows nothing rather than discarding a read the + // operator just performed. The apply path still owns every refusal. + assert!(source_entry_bindings(&fabricated_source(), &[]).is_empty()); + } + const CAPTURED_COMPANY: &str = "WR2 Unicode Lab"; const CAPTURED_GUID: &str = "61c6de69-1748-461c-ad3f-162cb949df9f"; diff --git a/src/source-draft-types.ts b/src/source-draft-types.ts index 3863a7f1..9f80539f 100644 --- a/src/source-draft-types.ts +++ b/src/source-draft-types.ts @@ -59,10 +59,26 @@ export type SourceDraftCompanyScope = { }; }; +/// One source entry's deterministic binding against the captured catalog. +/// Advisory: it narrows the target list and confers no authority. Applying a +/// name still goes through the unchanged assign path, which rereads the +/// catalog and proves the selection is current. +export type SourceDraftCatalogBinding = { + row_position: number; + entry_position: number; + bound_target: string | null; + bound_basis: "identifier" | "exact_name" | "normalized_name" | null; + unbound_reason: string | null; + candidates: string[]; + candidate_count: number; + candidates_truncated: boolean; +}; + export type SourceDraftCatalogTargets = { capture_id: string; source_sha256: string; targets: string[]; + bindings: SourceDraftCatalogBinding[]; evidence: { request_sha256: string; response_sha256: string; bytes: number; state: "complete" }; }; diff --git a/tools/Cargo.lock b/tools/Cargo.lock index 70443a14..e9765496 100644 --- a/tools/Cargo.lock +++ b/tools/Cargo.lock @@ -92,6 +92,7 @@ dependencies = [ "serde_json", "sha2", "thiserror", + "unicode-normalization", ] [[package]] @@ -1648,6 +1649,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "untrusted" version = "0.9.0" From 407b7829aecd3bc0a5f8b6d898efae080418e1f7 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Thu, 10 Sep 2026 22:13:02 +0530 Subject: [PATCH 02/91] Retain observed master names verbatim, and characterize the rules at scale Self-review found a real defect: a catalog name was trimmed at the boundary, so a bound row reported a spelling the book does not contain. The write gate compares byte-exact against Tally's own name, so that would have refused with no explanation. Observed names are now retained verbatim; only source names are trimmed. Names differing solely in surrounding whitespace are an ambiguity, not a refused catalog. Adds a characterization suite over one fabricated 200-master book with the recorded naming pathologies. The assertion that matters is that no entity binds to a master a human would not have chosen; the counts are pinned underneath so loosening a threshold has to move a number. The mutation sweep was checked against two positive controls rather than trusted for passing: resolving a near-miss to its first candidate trips it, and binding a lone candidate does not. Both results are recorded in the test, so it is read as "no mutation reaches the wrong master" and not as "no rule change can loosen binding". Co-Authored-By: Claude Opus 5 --- .../bridge-tally-core/src/master_binding.rs | 32 +- .../src/master_binding_tests.rs | 351 ++++++++++++++++++ 2 files changed, 377 insertions(+), 6 deletions(-) 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 88672867..5a70a923 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -67,6 +67,9 @@ pub enum MasterBindingError { #[error("master catalog was empty")] CatalogEmpty, /// Two masters carry byte-identical names, so a name cannot identify one. + /// Names differing only in surrounding whitespace are *not* duplicates — + /// they are retained verbatim and collide on the comparison key instead, + /// which surfaces them as an ambiguity rather than failing the read. #[error("master catalog carried a duplicate name")] CatalogDuplicateName, #[error("master catalog exceeded its bound")] @@ -426,7 +429,7 @@ impl SourceEntity { name: &str, hints: impl IntoIterator, ) -> Result { - let name = validated_name(name)?; + let name = validated_source_name(name)?; let mut identifiers = extract_identifiers(&name); for hint in hints { let extracted = extract_identifiers(hint); @@ -498,7 +501,7 @@ impl MasterCatalog { if entries.len() >= MAX_CATALOG_ENTRIES { return Err(MasterBindingError::CatalogTooLarge); } - let name = validated_name(name.as_ref())?; + let name = validated_catalog_name(name.as_ref())?; if by_name.contains_key(&name) { return Err(MasterBindingError::CatalogDuplicateName); } @@ -806,9 +809,26 @@ fn collect_candidates( .collect() } -fn validated_name(value: &str) -> Result { - let value = value.trim(); - if value.is_empty() { +/// An observed master name is retained **verbatim**. Surrounding whitespace is +/// part of what the book returned, and a caller that acts on a binding writes +/// this string back to Tally byte for byte; trimming it here would report a +/// spelling that does not exist and refuse at the write gate with no +/// explanation. The comparison key collapses whitespace anyway, so a source +/// name still matches across the difference. +fn validated_catalog_name(value: &str) -> Result { + validate_name_bounds(value)?; + Ok(value.to_string()) +} + +/// A source name is trimmed: leading and trailing whitespace is document noise +/// rather than an observation, and nothing is ever written back from it. +fn validated_source_name(value: &str) -> Result { + validate_name_bounds(value)?; + Ok(value.trim().to_string()) +} + +fn validate_name_bounds(value: &str) -> Result<(), MasterBindingError> { + if value.trim().is_empty() { return Err(MasterBindingError::NameBlank); } if value.chars().any(char::is_control) { @@ -817,7 +837,7 @@ fn validated_name(value: &str) -> Result { if value.chars().count() > MAX_NAME_CHARS { return Err(MasterBindingError::NameTooLong); } - Ok(value.to_string()) + Ok(()) } /// Folds the punctuation an operator happened to type: NFC-equivalent dash and 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 fbaedf54..c48bd4e1 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 @@ -82,6 +82,30 @@ fn unusable_names_are_refused_at_the_boundary() { assert_eq!(SourceEntity::new(0, ""), Err(MasterBindingError::NameBlank)); } +#[test] +fn an_observed_master_name_is_retained_verbatim_while_a_source_name_is_trimmed() { + // A caller writes the bound name back to Tally byte for byte. Trimming an + // observed name here would report a spelling that does not exist and + // refuse at the write gate with no explanation. + 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.source_name, "Alpha Traders"); +} + +#[test] +fn names_differing_only_in_surrounding_whitespace_are_an_ambiguity_not_a_refused_catalog() { + let catalog = ledgers(&["Alpha Traders", "Alpha Traders "]); + let binding = bind_one_name(&catalog, "Alpha Traders"); + // Byte equality still picks the exact one; the near-identical sibling is + // not a reason to fail the whole read. + assert_eq!(binding.bound_name(), Some("Alpha Traders")); + let other = bind_one_name(&catalog, "alpha traders"); + assert_eq!(reason(&other), UnboundReason::NameAmbiguous); + assert_eq!(candidate_names(&other), ["Alpha Traders", "Alpha Traders "]); +} + #[test] fn an_identifier_hint_that_yields_nothing_is_refused_rather_than_ignored() { assert_eq!( @@ -505,3 +529,330 @@ fn a_bound_status_serializes_without_a_score_field() { assert!(json.get("score").is_none()); assert!(json.get("confidence").is_none()); } + +// --------------------------------------------------------------------------- +// Characterization against a realistically shaped book +// +// Every rule above is tested in isolation on a handful of names. Three of the +// rules only engage at scale — common-token suppression needs 20+ entries, +// candidate capping needs 25+, and the prefix ranges only matter when many +// keys share a head — so their interaction is untested by any of it. +// +// This section fabricates one 200-master catalog carrying the naming +// pathologies actually recorded (a firm word on most ledgers, numbers typed +// into party names, a near-duplicate sales trio, a masked bank last-four) and +// pins the *outcome* for a document-sized set of source names. +// +// The assertion that matters is not the count. It is that **no entity binds to +// a master a human would not have chosen**: a wrong bind puts money against the +// wrong party, and is strictly worse than an unbound row. The counts are pinned +// underneath it so that loosening a threshold has to move a number in a diff. +// +// This is fabricated input. It characterizes the rules and is not evidence +// about any Tally instance or any real book's bindability. +// --------------------------------------------------------------------------- + +const GREEK: [&str; 20] = [ + "ALPHA", "BETA", "GAMMA", "DELTA", "EPSILON", "ZETA", "ETA", "THETA", "IOTA", "KAPPA", + "LAMBDA", "MU", "NU", "XI", "OMICRON", "PI", "RHO", "SIGMA", "TAU", "UPSILON", +]; + +/// One fabricated book: 200 ledgers, shaped like a small trading firm's. +fn fabricated_book() -> Vec { + let mut names = Vec::new(); + // 20 party ledgers with a number typed into the name, as operators do. + for (index, greek) in GREEK.iter().enumerate() { + names.push(format!( + "{greek} PLACEHOLDER ({})", + 5_550_001_001_u64 + index as u64 + )); + } + // 20 party ledgers without one. + for greek in GREEK { + names.push(format!("{greek} PLACEHOLDER TRADING CO")); + } + // The near-duplicate trio that one engagement actually met. + names.push("ALPHA SALE".to_string()); + names.push("ALPHA SALES".to_string()); + names.push("SALES - ALPHA".to_string()); + // Tax heads, which share heavy word overlap with each other. + for head in ["CGST", "SGST", "IGST"] { + for side in ["OUTPUT", "INPUT"] { + for rate in ["9%", "18%"] { + names.push(format!("{head} {side} {rate}")); + } + } + } + // Banks, carrying a masked last-four rather than a full account number. + names.push("PLACEHOLDER BANK CA 2129".to_string()); + names.push("PLACEHOLDER BANK OD 7745".to_string()); + // The accounts every book has. + for name in [ + "Cash", + "Suspense Placeholder", + "Round Off", + "Profit & Loss A/c", + ] { + names.push(name.to_string()); + } + // Filler carrying one firm-wide word, to the size of a real small book. + let mut index = 0; + while names.len() < 200 { + names.push(format!("PLACEHOLDER UNIT {index:03}")); + index += 1; + } + names +} + +#[derive(Debug, PartialEq, Eq)] +enum Expected { + /// The master a human reading the source would have chosen. + Bound(&'static str), + Unbound(UnboundReason), +} + +/// What one document names, and what a human would do with each. +fn fabricated_document() -> Vec<(&'static str, Option<&'static str>, Expected)> { + vec![ + // Named exactly as the book spells it. + ("Cash", None, Expected::Bound("Cash")), + ("CGST OUTPUT 9%", None, Expected::Bound("CGST OUTPUT 9%")), + // Case and spacing noise from the source system. + ( + " cgst output 9% ", + None, + Expected::Bound("CGST OUTPUT 9%"), + ), + ( + "beta placeholder trading co", + None, + Expected::Bound("BETA PLACEHOLDER TRADING CO"), + ), + // The engagement case: the source names the party its own way and + // carries the number in a separate column. Name matching would offer + // twenty wrong parties; the number decides. + ( + "GAMMA. K.", + Some("5550001003"), + Expected::Bound("GAMMA PLACEHOLDER (5550001003)"), + ), + // Same, with the number inside the name rather than a hint. + ( + "DELTA K 5550001004", + None, + Expected::Bound("DELTA PLACEHOLDER (5550001004)"), + ), + // A truncated party name: one candidate, and one candidate is still + // not a decision. + ( + "EPSILON PLACEHOLDER TRADING", + None, + Expected::Unbound(UnboundReason::NearMiss), + ), + // The near-duplicate trio. Nothing here may resolve. + ("ALPHA SALE", None, Expected::Bound("ALPHA SALE")), + ("ALPHA", None, Expected::Unbound(UnboundReason::NearMiss)), + // A masked bank last-four must not bind on four digits. + ( + "PLACEHOLDER BANK 2129", + None, + Expected::Unbound(UnboundReason::NearMiss), + ), + // A party the book simply does not have. + ( + "OMEGA WHOLESALE", + None, + Expected::Unbound(UnboundReason::NoCandidate), + ), + // A number the book does not carry: the hint finds nothing, and the + // name is left to answer on its own. + ( + "PSI SUPPLY", + Some("5559999999"), + Expected::Unbound(UnboundReason::NoCandidate), + ), + ] +} + +#[test] +fn a_document_against_a_realistic_book_binds_only_where_a_human_would() { + let names = fabricated_book(); + assert_eq!(names.len(), 200); + let catalog = + MasterCatalog::new(MasterClass::Ledger, &names).expect("the fabricated book is valid"); + assert_eq!(catalog.master_count(), 200); + + let document = fabricated_document(); + let entities = document + .iter() + .enumerate() + .map(|(position, (name, hint, _))| match hint { + Some(hint) => SourceEntity::with_identifier_hints(position, name, [*hint]), + None => SourceEntity::new(position, name), + }) + .map(|entity| entity.expect("fabricated source names are valid")) + .collect::>(); + let report = bound(&catalog, &entities); + + for ((source, _, expected), binding) in document.iter().zip(report.entities()) { + match (&binding.status, expected) { + (BindingStatus::Bound { catalog_name, .. }, Expected::Bound(intended)) => assert_eq!( + catalog_name, intended, + "{source:?} bound to a master a human would not have chosen" + ), + ( + BindingStatus::Ambiguous(unresolved) | BindingStatus::Unmatched(unresolved), + Expected::Unbound(intended), + ) => assert_eq!( + unresolved.reason, *intended, + "{source:?} was unbound for an unintended reason" + ), + (status, expected) => { + panic!("{source:?}: expected {expected:?}, got {status:?}") + } + } + } + + // The shape of the answer, pinned so a loosened threshold moves a number. + let totals = report.totals(); + assert_eq!(totals.requested, 12); + assert_eq!(totals.bound, 7); + assert_eq!(totals.ambiguous, 3); + assert_eq!(totals.unmatched, 2); + assert_eq!(totals.requested, totals.bound + totals.unbound); +} + +#[test] +fn a_firm_wide_word_does_not_drag_the_whole_book_into_every_candidate_list() { + // "placeholder" is carried by most of this book. Without suppression the + // unbound list stops being a work item and becomes a second data-entry job. + let names = fabricated_book(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let carriers = names + .iter() + .filter(|name| name.to_lowercase().contains("placeholder")) + .count(); + assert!(carriers > 100, "the fabricated book must exercise this"); + + let binding = bind_one_name(&catalog, "OMEGA PLACEHOLDER"); + let unresolved = binding.unresolved().expect("unbound"); + assert!( + unresolved.candidate_count <= MAX_CANDIDATES_PER_ENTITY, + "a firm-wide word pulled in {} candidates", + unresolved.candidate_count + ); +} + +/// The mutations a source document actually applies to a name it copied from +/// somewhere else: case, spacing, a dropped tail, a dropped last word. +fn source_mutations(name: &str) -> Vec { + let mut mutations = vec![name.to_uppercase(), name.to_lowercase()]; + mutations.push(format!( + " {} ", + name.split_whitespace().collect::>().join(" ") + )); + let characters = name.chars().collect::>(); + let kept = characters.len() * 4 / 5; + if kept >= MIN_PREFIX_KEY_CHARS { + mutations.push(characters[..kept].iter().collect()); + } + let words = name.split_whitespace().collect::>(); + if words.len() > 2 { + mutations.push(words[..words.len() - 1].join(" ")); + } + mutations +} + +#[test] +fn no_mutation_of_a_master_name_ever_binds_to_a_different_master() { + // The safety property, stated over a whole book rather than three chosen + // names: a wrong bind puts money against the wrong party, and is strictly + // worse than an unbound row. Roughly a thousand cases. + // + // Mutations that collide with *another* master under the comparison key + // are excluded, and deliberately so: truncating `ALPHA SALES` by one + // character yields `ALPHA SALE`, which is a real and different ledger. No + // rule can distinguish a truncation of one name from an exact spelling of + // another, and binding it to the name it actually spells is correct. + // + // **This sweep was checked against two positive controls**, because an + // assertion that has never failed is not yet known to be an instrument: + // + // - Resolving a near-miss to its first-ordered candidate — precisely what + // the deleted MCP helper did through `exact_live_spelling` — trips it on + // a truncated party name. So it does report presence. + // - Binding a *lone* candidate does **not** trip it, because in this book a + // lone candidate is nearly always the master the mutation came from. That + // regression is caught by `a_single_candidate_still_does_not_bind` and by + // the two prefix tests instead. + // + // Read this test as "no mutation reaches the wrong master", never as "no + // rule change can loosen binding". + let names = fabricated_book(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let keys = names + .iter() + .map(|name| (comparison_key(name), name.as_str())) + .collect::>(); + + let mut checked = 0_usize; + let mut self_bound = 0_usize; + for name in &names { + for mutation in source_mutations(name) { + let key = comparison_key(&mutation); + if keys.get(&key).is_some_and(|owner| owner != name) { + continue; // the mutation spells a different real ledger + } + checked += 1; + let binding = bind_one_name(&catalog, &mutation); + match binding.bound_name() { + None => {} + Some(bound_to) => { + assert_eq!( + bound_to, name, + "mutation {mutation:?} of {name:?} bound to a different master" + ); + self_bound += 1; + } + } + } + } + assert!( + checked > 900, + "the sweep must actually cover the book: {checked}" + ); + // Most mutations are case and spacing noise, which must still bind. + assert!( + self_bound * 2 > checked, + "only {self_bound} of {checked} mutations bound at all" + ); +} + +#[test] +fn a_number_typed_into_a_master_name_finds_it_from_any_source_name() { + // The rule that decided the case fuzzy matching got wrong, exercised + // against every party ledger in the book rather than one. + let names = fabricated_book(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let numbered = names + .iter() + .filter(|name| name.contains("PLACEHOLDER (")) + .collect::>(); + assert_eq!(numbered.len(), 20); + + for name in numbered { + let number = name + .rsplit_once('(') + .and_then(|(_, tail)| tail.strip_suffix(')')) + .expect("fabricated party names carry a number"); + // A source name sharing nothing with the ledger name at all. + let entity = SourceEntity::with_identifier_hints(0, "UNRELATED SOURCE PARTY", [number]) + .expect("valid"); + let report = bound(&catalog, &[entity]); + assert_eq!( + report.entities()[0].bound_name(), + Some(name.as_str()), + "the number typed into {name:?} did not find it" + ); + } +} From 81bc763622aa59e2f34721dc2b93b5854178ec5d Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Thu, 10 Sep 2026 23:37:14 +0530 Subject: [PATCH 03/91] Fix the review findings, and the candidate quality live data exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 14 +- .../bridge-tally-core/src/master_binding.rs | 207 ++++++++++++------ .../src/master_binding_tests.rs | 115 +++++++++- src-tauri/src/agent_import.rs | 10 +- src-tauri/src/agent_import_tests.rs | 16 +- src-tauri/src/source_draft/catalog.rs | 59 +++-- src/source-draft-types.ts | 3 + tools/bridge-tally-compatibility/src/lib.rs | 14 +- 9 files changed, 336 insertions(+), 104 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index f9348144..1125d95d 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": "3181d64fe41764a34c11a588a191c5a9df1f17152b4b90091cfaadf88829472f", + "compatibility_surface_sha256": "09bc6b215b23e5a4feed7210e0289773a1f415846c714e918fa0d00d67c4c944", "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 92e70e12..d42e3eb7 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -141,6 +141,10 @@ "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", "sha256": "58674602eb3131c101ace7638d43cf550d74b29eb3848b0c908687bb2c9fbf9c" }, + { + "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", + "sha256": "253f74e93977d04c1744aab03608eb06de7acff70b85fe549a0b95a68f4b443c" + }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", "sha256": "7579b5bfcf7aca89dd688043d3b3d8995a42403fac3ee8bd2c66ec0380b79aa4" @@ -327,7 +331,7 @@ }, { "path": "src-tauri/src/agent_import.rs", - "sha256": "007c1c97eb65929734345fe72b8b52a9cee8ca6adcb375654080a71f6576ca3a" + "sha256": "f531cd0c3bc2a5da6b6dc53271c473bf0b1e1ef776966692a4579651989a67da" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -575,7 +579,7 @@ }, { "path": "src-tauri/src/source_draft/catalog.rs", - "sha256": "f283ace5705e679b9a25ea605fd53fc7775878b93065b0c552f4c4c9234d312d" + "sha256": "53cfe9d0d248af44361b7d9cb314e301cc0dba87547c54bd59ea90ae35117325" }, { "path": "src-tauri/src/source_draft/files.rs", @@ -771,7 +775,7 @@ }, { "path": "src/source-draft-types.ts", - "sha256": "429d2b69a2948f8fdcb7da602c5b895d2dff42c3db7f48ba44fc9d10db701321" + "sha256": "de6b108f6654d6ae4bcf1855e9e3a758412472d694e1b2124eff6553315dd0e5" }, { "path": "src/source-draft.css", @@ -807,7 +811,7 @@ }, { "path": "tools/bridge-tally-compatibility/src/lib.rs", - "sha256": "4c07fdfe42bf7fd5010068d717799b3b9c52a3dbd5bc2c85850855f184d6d229" + "sha256": "8b07e99c7335d6207664302aea4fd800ed524201c8c50d1c7edcf02982d1bce1" }, { "path": "tools/bridge-tally-compatibility/src/main.rs", @@ -842,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "3181d64fe41764a34c11a588a191c5a9df1f17152b4b90091cfaadf88829472f" + "manifest_sha256": "09bc6b215b23e5a4feed7210e0289773a1f415846c714e918fa0d00d67c4c944" } \ No newline at end of file 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 5a70a923..cb119426 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -18,8 +18,11 @@ use unicode_normalization::UnicodeNormalization; /// Most masters one catalog may carry. pub const MAX_CATALOG_ENTRIES: usize = 20_000; -/// Most entities one binding request may name. -pub const MAX_SOURCE_ENTITIES: usize = 5_000; +/// Most entities one binding request may name. This must stay at or above what +/// a consumer's own parser admits: Bridge's source-draft parser accepts 2,000 +/// vouchers of 20 entries, and a bound below that turned a valid draft into a +/// silently empty binding result. +pub const MAX_SOURCE_ENTITIES: usize = 40_000; /// Longest accepted master or source name, in characters. This bounds /// pathological input; it is not a claim about what Tally accepts, and a /// caller with a stricter contract of its own enforces that at its own @@ -27,8 +30,9 @@ pub const MAX_SOURCE_ENTITIES: usize = 5_000; pub const MAX_NAME_CHARS: usize = 16_384; /// Most candidates retained per unbound entity. pub const MAX_CANDIDATES_PER_ENTITY: usize = 25; -/// Most identifiers extracted from one name. -pub const MAX_IDENTIFIERS_PER_NAME: usize = 8; +/// Most identifiers one name may carry. Exceeding it is refused, never +/// truncated. +pub const MAX_IDENTIFIERS_PER_NAME: usize = 32; /// Digits a numeric run needs before it is treated as an identifier. Eight /// excludes a year, a rate, a house number and a masked last-four; a mobile, /// an account number and a customer code all clear it. @@ -42,6 +46,10 @@ pub const MIN_CODE_IDENTIFIER_DIGITS: usize = 2; pub const MIN_PREFIX_KEY_CHARS: usize = 3; /// Shortest token that may take part in a shared-token near-miss. pub const MIN_TOKEN_CHARS: usize = 3; +/// Masters a single prefix may match before the prefix stops discriminating. +/// Beyond this the match is a name *family*, and an arbitrary slice of it is +/// worse than saying so. +pub const MAX_PREFIX_FAMILY: usize = MAX_CANDIDATES_PER_ENTITY; /// Share of the catalog above which a token stops discriminating. pub const COMMON_TOKEN_PERCENT: usize = 10; /// Catalog size below which no token is treated as common. @@ -86,6 +94,10 @@ pub enum MasterBindingError { /// silently, so it fails loudly instead. #[error("identifier hint carried no usable identifier")] IdentifierHintUnusable, + /// Keeping only the first few would discard the identifier that pointed at + /// a different master, turning a conflict into a bind. + #[error("name carried more identifiers than the bound")] + TooManyIdentifiers, #[error("fallback master was not a current catalog entry")] FallbackNotInCatalog, } @@ -102,6 +114,7 @@ impl MasterBindingError { Self::NameTooLong => "master_name_too_long", Self::NameUnsafe => "master_name_unsafe", Self::IdentifierHintUnusable => "master_identifier_hint_unusable", + Self::TooManyIdentifiers => "master_identifiers_too_many", Self::FallbackNotInCatalog => "master_fallback_not_in_catalog", } } @@ -176,6 +189,12 @@ pub enum UnboundReason { /// Candidates exist but none was decisive. This is the four-near-miss case /// that rejected a batch once; a single candidate stays here too. NearMiss, + /// The source name matches a whole family of masters and distinguishes + /// none of them — a truncated `DN Party 0` against `DN Party 001`…`120`. + /// Measured live: listing an arbitrary capped slice of such a family put + /// the right master out of view about a third of the time, so the family + /// is counted and deliberately not listed. + NoDiscriminatingCandidate, /// No rule produced a candidate. The master is probably missing. NoCandidate, } @@ -188,6 +207,7 @@ impl UnboundReason { Self::IdentifierNameConflict => "master_binding_identifier_name_conflict", Self::NameAmbiguous => "master_binding_name_ambiguous", Self::NearMiss => "master_binding_near_miss", + Self::NoDiscriminatingCandidate => "master_binding_no_discriminating_candidate", Self::NoCandidate => "master_binding_no_candidate", } } @@ -429,10 +449,10 @@ impl SourceEntity { name: &str, hints: impl IntoIterator, ) -> Result { - let name = validated_source_name(name)?; - let mut identifiers = extract_identifiers(&name); + let name = validated_name(name)?; + let mut identifiers = extract_identifiers(&name)?; for hint in hints { - let extracted = extract_identifiers(hint); + let extracted = extract_identifiers(hint)?; if extracted.is_empty() { return Err(MasterBindingError::IdentifierHintUnusable); } @@ -440,7 +460,9 @@ impl SourceEntity { } identifiers.sort(); identifiers.dedup(); - identifiers.truncate(MAX_IDENTIFIERS_PER_NAME); + if identifiers.len() > MAX_IDENTIFIERS_PER_NAME { + return Err(MasterBindingError::TooManyIdentifiers); + } Ok(Self { position, key: comparison_key(&name), @@ -501,14 +523,14 @@ impl MasterCatalog { if entries.len() >= MAX_CATALOG_ENTRIES { return Err(MasterBindingError::CatalogTooLarge); } - let name = validated_catalog_name(name.as_ref())?; + let name = validated_name(name.as_ref())?; if by_name.contains_key(&name) { return Err(MasterBindingError::CatalogDuplicateName); } by_name.insert(name.clone(), entries.len()); let key = comparison_key(&name); entries.push(CatalogEntry { - identifiers: extract_identifiers(&name), + identifiers: extract_identifiers(&name)?, tokens: tokens_of(&key), key, name, @@ -636,19 +658,26 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { // An identifier pointing at one master while the name exactly names // another is a disagreement between two strong signals; it is shown, // not silently decided in the identifier's favour. - if exact.is_some_and(|index| index != matched) { - unresolved_status( + match exact { + // Two strong signals disagreeing is shown, not settled. + Some(index) if index != matched => unresolved_status( catalog, entity, UnboundReason::IdentifierNameConflict, exact, &identifier_matches, - ) - } else { - BindingStatus::Bound { + ), + // They agree. Report the stronger, byte-level fact: the write gate + // admits `ExactName` only, and reporting `Identifier` here made + // every ledger carrying a number permanently unimportable. + Some(_) => BindingStatus::Bound { + catalog_name: catalog.entries[matched].name.clone(), + basis: BindingBasis::ExactName, + }, + None => BindingStatus::Bound { catalog_name: catalog.entries[matched].name.clone(), basis: BindingBasis::Identifier, - } + }, } } else if let Some(index) = exact { BindingStatus::Bound { @@ -669,13 +698,16 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { &identifier_matches, ), None => { - let candidates = collect_candidates(catalog, entity, &identifier_matches); - let reason = if candidates.is_empty() { - UnboundReason::NoCandidate - } else { + let (candidates, prefix_family) = + collect_candidates(catalog, entity, &identifier_matches); + let reason = if !candidates.is_empty() { UnboundReason::NearMiss + } else if prefix_family > MAX_PREFIX_FAMILY { + UnboundReason::NoDiscriminatingCandidate + } else { + UnboundReason::NoCandidate }; - unresolved_from(entity, reason, candidates) + unresolved_from(entity, reason, candidates, prefix_family) } } }; @@ -694,20 +726,21 @@ fn unresolved_status( exact: Option, identifier_matches: &BTreeSet, ) -> BindingStatus { - let mut candidates = collect_candidates(catalog, entity, identifier_matches); + let (mut candidates, prefix_family) = collect_candidates(catalog, entity, identifier_matches); if let Some(index) = exact { let name = catalog.entries[index].name.as_str(); if !candidates.iter().any(|(candidate, _)| candidate == name) { candidates.push((name.to_string(), CandidateRule::NormalizedEqual)); } } - unresolved_from(entity, reason, candidates) + unresolved_from(entity, reason, candidates, prefix_family) } fn unresolved_from( entity: &SourceEntity, reason: UnboundReason, candidates: Vec<(String, CandidateRule)>, + prefix_family: usize, ) -> BindingStatus { let mut ordered = candidates; ordered.sort_by(|left, right| { @@ -716,8 +749,10 @@ fn unresolved_from( .cmp(&right.1.rank()) .then_with(|| left.0.cmp(&right.0)) }); - let candidate_count = ordered.len(); - let candidates_truncated = candidate_count > MAX_CANDIDATES_PER_ENTITY; + // A suppressed family is still counted. The operator is told how many + // masters the name reaches even when none of them is worth listing. + let candidate_count = ordered.len().max(prefix_family); + let candidates_truncated = candidate_count > ordered.len().min(MAX_CANDIDATES_PER_ENTITY); let candidates = ordered .into_iter() .take(MAX_CANDIDATES_PER_ENTITY) @@ -744,7 +779,7 @@ fn collect_candidates( catalog: &MasterCatalog, entity: &SourceEntity, identifier_matches: &BTreeSet, -) -> Vec<(String, CandidateRule)> { +) -> (Vec<(String, CandidateRule)>, usize) { let mut best: BTreeMap = BTreeMap::new(); let mut offer = |index: usize, rule: CandidateRule| { best.entry(index) @@ -764,18 +799,24 @@ fn collect_candidates( offer(*index, CandidateRule::NormalizedEqual); } } + let mut prefix_family = 0_usize; if entity.key.chars().count() >= MIN_PREFIX_KEY_CHARS { // The key index is ordered, so both prefix directions are range or // point lookups rather than a scan of the whole catalog per entity. - for (key, holders) in catalog.by_key.range(entity.key.clone()..) { - if !key.starts_with(&entity.key) { - break; - } - if key == &entity.key { - continue; - } - for index in holders { - offer(*index, CandidateRule::CatalogPrefix); + let extending = catalog + .by_key + .range(entity.key.clone()..) + .take_while(|(key, _)| key.starts_with(&entity.key)) + .filter(|(key, _)| *key != &entity.key) + .flat_map(|(_, holders)| holders.iter().copied()) + .collect::>(); + prefix_family = extending.len(); + // A prefix matching a whole family distinguishes nothing inside it, and + // an arbitrary capped slice is worse than none: measured against live + // books, that slice omitted the right master about a third of the time. + if prefix_family <= MAX_PREFIX_FAMILY { + for index in extending { + offer(index, CandidateRule::CatalogPrefix); } } for split in MIN_PREFIX_KEY_CHARS..entity.key.len() { @@ -804,29 +845,29 @@ fn collect_candidates( } } - best.into_iter() - .map(|(index, rule)| (catalog.entries[index].name.clone(), rule)) - .collect() + ( + best.into_iter() + .map(|(index, rule)| (catalog.entries[index].name.clone(), rule)) + .collect(), + prefix_family, + ) } -/// An observed master name is retained **verbatim**. Surrounding whitespace is -/// part of what the book returned, and a caller that acts on a binding writes -/// this string back to Tally byte for byte; trimming it here would report a -/// spelling that does not exist and refuse at the write gate with no -/// explanation. The comparison key collapses whitespace anyway, so a source -/// name still matches across the difference. -fn validated_catalog_name(value: &str) -> Result { +/// A name is retained **verbatim**, on both sides. +/// +/// An observed master name is written back to Tally byte for byte by a caller +/// that acts on a binding, so trimming it would report a spelling the book does +/// not contain. A requested source name is what byte equality is judged +/// 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. +fn validated_name(value: &str) -> Result { validate_name_bounds(value)?; Ok(value.to_string()) } -/// A source name is trimmed: leading and trailing whitespace is document noise -/// rather than an observation, and nothing is ever written back from it. -fn validated_source_name(value: &str) -> Result { - validate_name_bounds(value)?; - Ok(value.trim().to_string()) -} - fn validate_name_bounds(value: &str) -> Result<(), MasterBindingError> { if value.trim().is_empty() { return Err(MasterBindingError::NameBlank); @@ -871,20 +912,15 @@ fn tokens_of(key: &str) -> BTreeSet { /// A numeric run may hold `-` and `/` internally, so a punctuated account /// number and a plain one agree; it may not hold spaces, so separated digit /// groups fail closed to a near-miss rather than fusing into a false -/// identifier. -fn extract_identifiers(value: &str) -> Vec { +/// identifier. Digits that sit inside a mixed letter-and-digit token belong to +/// that token's code and are never also emitted on their own — otherwise +/// `Part AB12345678` would collide with an unrelated `Bank 12345678`. +/// +/// Refuses rather than truncates when a name carries more identifiers than the +/// bound: silently keeping the first few can turn a conflict into a bind by +/// discarding the identifier that pointed elsewhere. +fn extract_identifiers(value: &str) -> Result, MasterBindingError> { let mut identifiers = BTreeSet::new(); - for run in value.split(|character: char| { - !(character.is_ascii_digit() || character == '-' || character == '/') - }) { - let digits = run.chars().filter(char::is_ascii_digit).collect::(); - if digits.len() >= MIN_NUMERIC_IDENTIFIER_DIGITS && !is_plausible_date(&digits) { - identifiers.insert(Identifier { - kind: IdentifierKind::Numeric, - value: digits, - }); - } - } for token in value.split(char::is_whitespace) { let canonical = token .chars() @@ -901,23 +937,48 @@ fn extract_identifiers(value: &str) -> Vec { kind: IdentifierKind::Code, value: canonical, }); + // Its digits are part of this code, not an identifier of their own. + continue; } + for run in token.split(|character: char| { + !(character.is_ascii_digit() || character == '-' || character == '/') + }) { + let digits = run.chars().filter(char::is_ascii_digit).collect::(); + if digits.len() >= MIN_NUMERIC_IDENTIFIER_DIGITS && !is_plausible_date(&digits) { + identifiers.insert(Identifier { + kind: IdentifierKind::Numeric, + value: digits, + }); + } + } + } + if identifiers.len() > MAX_IDENTIFIERS_PER_NAME { + return Err(MasterBindingError::TooManyIdentifiers); } - let mut identifiers = identifiers.into_iter().collect::>(); - identifiers.truncate(MAX_IDENTIFIERS_PER_NAME); - identifiers + Ok(identifiers.into_iter().collect()) } -/// An eight-digit run that reads as a calendar date is a date. Excluding it -/// costs a near-miss on an account number that happens to look like one, and -/// prevents a period label binding two unrelated masters together. +/// An eight-digit run that reads as a calendar date in any order this project +/// admits is a date, not an identifier. Recognizing only `YYYYMMDD` left +/// `01012026` binding a source to an unrelated master that shares its period +/// label. Being generous here can only make a bind *less* likely, which is the +/// safe direction for a rule whose failure mode is money against the wrong +/// party. fn is_plausible_date(digits: &str) -> bool { if digits.len() != 8 { return false; } let number = |range: std::ops::Range| digits[range].parse::().unwrap_or(0); - let (year, month, day) = (number(0..4), number(4..6), number(6..8)); - (1900..=2199).contains(&year) && (1..=12).contains(&month) && (1..=31).contains(&day) + let (first, second, third, fourth) = (number(0..4), number(4..6), number(6..8), number(4..8)); + let (day, month) = (number(0..2), number(2..4)); + let year_first = + (1900..=2199).contains(&first) && (1..=12).contains(&second) && (1..=31).contains(&third); + // DDMMYYYY and MMDDYYYY are indistinguishable from each other without a + // locale, so either reading is enough to disqualify the run. + let year_last = (1900..=2199).contains(&fourth) + && ((1..=31).contains(&day) && (1..=12).contains(&month) + || (1..=12).contains(&day) && (1..=31).contains(&month)); + year_first || year_last } #[cfg(test)] 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 c48bd4e1..96e6fb10 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 @@ -352,6 +352,94 @@ fn separated_digit_groups_do_not_fuse_into_an_identifier() { assert!(entity.identifiers().is_empty()); } +// --- the review findings, pinned ------------------------------------------- + +#[test] +fn a_byte_exact_name_carrying_a_number_is_reported_exact_not_identifier() { + // The write gate admits `ExactName` only. Reporting `Identifier` when the + // two agree made every ledger with a number in its name permanently + // unimportable — the exact population this contract exists to serve. + let catalog = ledgers(&["GAMMA (5550000001)", "GAMMA ALPHA"]); + let binding = bind_one_name(&catalog, "GAMMA (5550000001)"); + assert_eq!( + binding.status, + BindingStatus::Bound { + catalog_name: "GAMMA (5550000001)".to_string(), + basis: BindingBasis::ExactName, + } + ); +} + +#[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. + 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.source_name, "Bank ", + "the requested value is echoed verbatim" + ); +} + +#[test] +fn digits_inside_a_mixed_code_are_not_also_a_standalone_identifier() { + // Otherwise `Part AB12345678` collides with an unrelated `Bank 12345678`. + let entity = entity("Part AB12345678"); + assert_eq!( + entity.identifiers(), + [Identifier { + kind: IdentifierKind::Code, + value: "AB12345678".to_string(), + }] + ); + let catalog = ledgers(&["Bank 12345678", "Beta Supply"]); + let binding = bind_one_name(&catalog, "Part AB12345678"); + assert_eq!( + binding.bound_name(), + None, + "a part code must not reach a bank ledger" + ); +} + +#[test] +fn an_eight_digit_date_in_any_admitted_order_is_not_an_identifier() { + for date in ["20260910", "01012026", "31122026", "12312026"] { + assert!( + entity(&format!("Period {date}")).identifiers().is_empty(), + "{date} was treated as an identifier" + ); + } + // A number that reads as no calendar date at all still is one. + assert_eq!(entity("Party 55500001").identifiers().len(), 1); +} + +#[test] +fn more_identifiers_than_the_bound_is_refused_not_truncated() { + // Keeping the first few can discard the identifier that pointed at a + // different master, turning a conflict into a bind. + let many = (0..MAX_IDENTIFIERS_PER_NAME + 1) + .map(|index| format!("5550{index:04}00")) + .collect::>() + .join(" "); + assert_eq!( + SourceEntity::new(0, &many), + Err(MasterBindingError::TooManyIdentifiers) + ); + assert_eq!( + MasterBindingError::TooManyIdentifiers.safe_reason_code(), + "master_identifiers_too_many" + ); +} + // --- candidate discipline -------------------------------------------------- #[test] @@ -367,18 +455,37 @@ fn a_catalog_wide_token_stops_discriminating() { } #[test] -fn candidates_are_capped_with_the_true_count_retained() { - let names = (0..MAX_CANDIDATES_PER_ENTITY + 5) +fn a_prefix_matching_a_whole_family_is_counted_and_deliberately_not_listed() { + // Measured against live books: listing an arbitrary capped slice of a name + // family put the right master out of view about a third of the time, + // because the slice is ordered by name and the family is uniform. Counting + // the family and listing none of it is the honest answer — the source name + // genuinely does not distinguish one from another. + let names = (0..MAX_PREFIX_FAMILY + 5) .map(|index| format!("ALPHAGROUP UNIT {index:02}")) .collect::>(); let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); let binding = bind_one_name(&catalog, "ALPHAGROUP"); let unresolved = binding.unresolved().expect("unbound"); - assert_eq!(unresolved.candidates.len(), MAX_CANDIDATES_PER_ENTITY); - assert_eq!(unresolved.candidate_count, MAX_CANDIDATES_PER_ENTITY + 5); + assert_eq!(reason(&binding), UnboundReason::NoDiscriminatingCandidate); + assert!(unresolved.candidates.is_empty()); + assert_eq!(unresolved.candidate_count, MAX_PREFIX_FAMILY + 5); assert!(unresolved.candidates_truncated); } +#[test] +fn a_family_within_the_bound_is_still_listed_in_full() { + let names = (0..MAX_PREFIX_FAMILY) + .map(|index| format!("ALPHAGROUP UNIT {index:02}")) + .collect::>(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let binding = bind_one_name(&catalog, "ALPHAGROUP"); + let unresolved = binding.unresolved().expect("unbound"); + assert_eq!(reason(&binding), UnboundReason::NearMiss); + assert_eq!(unresolved.candidates.len(), MAX_PREFIX_FAMILY); + assert!(!unresolved.candidates_truncated); +} + #[test] fn candidate_order_is_rule_then_name_and_never_a_ranking() { let catalog = ledgers(&[ diff --git a/src-tauri/src/agent_import.rs b/src-tauri/src/agent_import.rs index f6eb39c7..e560b5c2 100644 --- a/src-tauri/src/agent_import.rs +++ b/src-tauri/src/agent_import.rs @@ -285,7 +285,15 @@ impl Server { &ledgers.into_iter().map(str::to_string).collect::>(), &catalogue, ) - .map_err(|code| ToolFailure::from(code).with_prior_evidence(identity_evidence.clone()))?; + // The catalogue read already succeeded, so its request/response + // commitments belong in the failure too; attaching identity evidence + // alone would omit a Tally read that actually happened. + .map_err(|code| { + ToolFailure::from(code).with_prior_evidence(combine_evidence( + identity_evidence.clone(), + evidence.clone(), + )) + })?; let hash = sha256_json(&catalogue); Ok(ToolOutcome { payload: json!({"company": company_json(&company, std::slice::from_ref(&company)), "result": {"masters": report, "catalogue_evidence_sha256": hash}}), diff --git a/src-tauri/src/agent_import_tests.rs b/src-tauri/src/agent_import_tests.rs index 66021e83..f42d5eec 100644 --- a/src-tauri/src/agent_import_tests.rs +++ b/src-tauri/src/agent_import_tests.rs @@ -1505,11 +1505,25 @@ fn master_match_bounds_suggestions_before_copying_names_and_preserves_ambiguity( .map(|index| format!("Ledger {index:03}")) .collect::>(); let borrowed = catalogue.iter().map(String::as_str).collect::>(); + // A name reaching a whole family distinguishes none of it. Measured against + // live books, listing an arbitrary capped slice omitted the right master + // about a third of the time, so the family is counted and not listed. let matched = one_master_match("Ledger", &borrowed); assert_eq!(matched["match_state"], "near_miss"); + assert_eq!( + matched["reason"], + "master_binding_no_discriminating_candidate" + ); assert_eq!(matched["candidate_count"], 100); assert_eq!(matched["candidates_truncated"], true); - assert_eq!(matched["candidates"].as_array().unwrap().len(), 25); + assert!(matched["candidates"].as_array().unwrap().is_empty()); + // A family inside the bound is still listed in full. + let small = (0..5) + .map(|index| format!("Small {index:02}")) + .collect::>(); + let small_ref = small.iter().map(String::as_str).collect::>(); + let listed = one_master_match("Small", &small_ref); + assert_eq!(listed["candidates"].as_array().unwrap().len(), 5); assert_eq!( one_master_match("Ledger 099", &borrowed)["match_state"], "exact" diff --git a/src-tauri/src/source_draft/catalog.rs b/src-tauri/src/source_draft/catalog.rs index 7505924e..ce2f3f38 100644 --- a/src-tauri/src/source_draft/catalog.rs +++ b/src-tauri/src/source_draft/catalog.rs @@ -55,6 +55,11 @@ pub(crate) struct SourceDraftCatalogTargets { pub(crate) source_sha256: String, pub(crate) targets: Vec, pub(crate) bindings: Vec, + /// `complete` when every source entry was bound, `unavailable` when the + /// narrowing pass could not run. An empty `bindings` list is otherwise + /// indistinguishable from a failed one, and the catalogue read itself still + /// succeeded. + pub(crate) bindings_state: &'static str, pub(crate) evidence: SourceDraftCatalogEvidence, } @@ -125,6 +130,9 @@ pub(super) struct CatalogApplySnapshot { pub(super) catalog: StandardLedgerCatalog, } +/// Total candidate-name bytes one catalogue-load response may carry. +const MAX_BINDING_CANDIDATE_BYTES: usize = 256 * 1024; + /// 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 @@ -132,9 +140,9 @@ pub(super) struct CatalogApplySnapshot { fn source_entry_bindings( source: &crate::source_draft_xml::ParsedSource, targets: &[String], -) -> Vec { +) -> (Vec, &'static str) { let Ok(catalog) = MasterCatalog::new(MasterClass::Ledger, targets) else { - return Vec::new(); + return (Vec::new(), "unavailable"); }; let mut located = Vec::new(); let mut entities = Vec::new(); @@ -148,9 +156,16 @@ fn source_entry_bindings( } } let Ok(report) = master_binding::bind(&catalog, &entities) else { - return Vec::new(); + // A refusal is reported as such. Returning an empty list here would let + // a failed pass read exactly like a source that narrowed to nothing. + return (Vec::new(), "unavailable"); }; - report + // Candidate names are cloned per entry, so a large draft whose entries all + // share a prefix could otherwise build tens of megabytes of duplicate text + // before serialization. The budget is spent in source order and every entry + // still reports its true count. + let mut budget = MAX_BINDING_CANDIDATE_BYTES; + let bindings = report .entities() .iter() .zip(located) @@ -170,24 +185,31 @@ fn source_entry_bindings( candidates_truncated: false, }, BindingStatus::Ambiguous(unresolved) | BindingStatus::Unmatched(unresolved) => { + let mut candidates = Vec::new(); + for candidate in &unresolved.candidates { + let Some(remaining) = budget.checked_sub(candidate.catalog_name.len()) + else { + break; + }; + budget = remaining; + candidates.push(candidate.catalog_name.clone()); + } SourceDraftCatalogBinding { row_position, entry_position, bound_target: None, bound_basis: None, unbound_reason: Some(unresolved.reason.safe_reason_code()), - candidates: unresolved - .candidates - .iter() - .map(|candidate| candidate.catalog_name.clone()) - .collect(), + candidates_truncated: unresolved.candidates_truncated + || candidates.len() < unresolved.candidates.len(), + candidates, candidate_count: unresolved.candidate_count, - candidates_truncated: unresolved.candidates_truncated, } } }, ) - .collect() + .collect(); + (bindings, "complete") } pub(super) fn require_current_catalog_binding( @@ -334,7 +356,7 @@ impl SourceDraftStore { return Err(error("source_draft_catalogue_invalidated")); } let targets = read.catalog.names().map(str::to_owned).collect::>(); - let bindings = source_entry_bindings(¤t.source, &targets); + let (bindings, bindings_state) = source_entry_bindings(¤t.source, &targets); let capture = CatalogCapture { id: Uuid::new_v4(), draft_id: current.id, @@ -350,6 +372,7 @@ impl SourceDraftStore { source_sha256: capture.source_sha256.clone(), targets, bindings, + bindings_state, evidence: SourceDraftCatalogEvidence { request_sha256: read.request_sha256, response_sha256: read.response_sha256, @@ -545,7 +568,8 @@ mod tests { "GAMMA ALPHA".to_string(), "Beta Supply".to_string(), ]; - let bindings = source_entry_bindings(&fabricated_source(), &targets); + let (bindings, state) = source_entry_bindings(&fabricated_source(), &targets); + assert_eq!(state, "complete"); assert_eq!(bindings.len(), 3); // Case alone does not defeat a bind, and the live spelling is named. @@ -572,10 +596,13 @@ mod tests { } #[test] - fn narrowing_is_advisory_and_never_fails_a_completed_capture() { + fn a_narrowing_pass_that_could_not_run_says_so_rather_than_looking_empty() { // An unusable capture narrows nothing rather than discarding a read the - // operator just performed. The apply path still owns every refusal. - assert!(source_entry_bindings(&fabricated_source(), &[]).is_empty()); + // operator just performed — but "no bindings" and "binding failed" must + // not read alike, because the catalogue read itself still succeeded. + let (bindings, state) = source_entry_bindings(&fabricated_source(), &[]); + assert!(bindings.is_empty()); + assert_eq!(state, "unavailable"); } const CAPTURED_COMPANY: &str = "WR2 Unicode Lab"; diff --git a/src/source-draft-types.ts b/src/source-draft-types.ts index 9f80539f..54956ee6 100644 --- a/src/source-draft-types.ts +++ b/src/source-draft-types.ts @@ -79,6 +79,9 @@ export type SourceDraftCatalogTargets = { source_sha256: string; targets: string[]; bindings: SourceDraftCatalogBinding[]; + /// "complete" when every source entry was bound; "unavailable" when the + /// narrowing pass could not run. An empty list alone cannot say which. + bindings_state: "complete" | "unavailable"; evidence: { request_sha256: string; response_sha256: string; bytes: number; state: "complete" }; }; diff --git a/tools/bridge-tally-compatibility/src/lib.rs b/tools/bridge-tally-compatibility/src/lib.rs index 73d16f39..61dc5ad1 100644 --- a/tools/bridge-tally-compatibility/src/lib.rs +++ b/tools/bridge-tally-compatibility/src/lib.rs @@ -30,7 +30,15 @@ pub const RESERVED_SURFACE_FILES: usize = 15; /// reserved capacity covers a small cohesive feature (source, tests, docs /// and manifest) but makes further unreviewed additions an explicit /// compatibility-surface decision. -pub const MAX_SURFACE_FILES: usize = 210; +/// +/// Raised from 210 to 211 to admit +/// `src-tauri/crates/bridge-tally-core/src/master_binding.rs`. That file +/// decides `validate_masters` results and, through them, import admission; +/// left unpinned, an edit confined to the matcher would leave the surface +/// digest unchanged and let existing evidence attest behaviour it never +/// covered. This is the deliberate decision the paragraph above requires, and +/// it is one file for one named reason — not headroom. +pub const MAX_SURFACE_FILES: usize = 211; pub const MAX_OPERATIONS: usize = 16; pub const MAX_CLAIMS: usize = 128; pub const MAX_KEYS: usize = 32; @@ -2456,10 +2464,10 @@ mod tests { } #[test] - fn surface_file_cap_refuses_211_entries() { + fn surface_file_cap_refuses_one_more_than_the_cap() { let oversized = CompatibilitySurfaceManifest { schema_version: SURFACE_SCHEMA_VERSION, - files: (0..211) + files: (0..MAX_SURFACE_FILES + 1) .map(|index| SurfaceFile { path: format!("pinned-{index:03}"), sha256: "0".repeat(64), From eea460efe5c2487dc87946f5f53167b0a052bc4d Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Thu, 10 Sep 2026 23:54:45 +0530 Subject: [PATCH 04/91] Scope fallback assignment to the report it came from The remaining review finding, and the one I had triaged but not fixed. FallbackBinding::assign took an EntityBinding detached from its report plus any catalog, so a stock-item binding could be parked against a ledger catalog and the result carried no provenance for anything downstream to detect. Assignment is now a method on BindingReport taking an index into its own entities, so an entity from another report cannot be named at all, the catalog class is checked, and the binding carries its class forward. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 69 ++++++++++++------- .../src/master_binding_tests.rs | 37 ++++++++-- 4 files changed, 80 insertions(+), 32 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 1125d95d..63a66d27 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": "09bc6b215b23e5a4feed7210e0289773a1f415846c714e918fa0d00d67c4c944", + "compatibility_surface_sha256": "49e84ae89b9fee801525eed75bdd5420f34400770354c77ee06e15449c371cde", "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 d42e3eb7..500c05a4 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "253f74e93977d04c1744aab03608eb06de7acff70b85fe549a0b95a68f4b443c" + "sha256": "6d41ba230dc4e63a53018287480c9f3c3724d4df2592b6f56c284ed4d8df8d41" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "09bc6b215b23e5a4feed7210e0289773a1f415846c714e918fa0d00d67c4c944" + "manifest_sha256": "49e84ae89b9fee801525eed75bdd5420f34400770354c77ee06e15449c371cde" } \ No newline at end of file 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 cb119426..adf04767 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -100,6 +100,9 @@ pub enum MasterBindingError { TooManyIdentifiers, #[error("fallback master was not a current catalog entry")] FallbackNotInCatalog, + /// A catalog of the wrong class, or an entity from another report. + #[error("catalog did not match the report it is used with")] + ClassMismatch, } impl MasterBindingError { @@ -116,6 +119,7 @@ impl MasterBindingError { Self::IdentifierHintUnusable => "master_identifier_hint_unusable", Self::TooManyIdentifiers => "master_identifiers_too_many", Self::FallbackNotInCatalog => "master_fallback_not_in_catalog", + Self::ClassMismatch => "master_class_mismatch", } } } @@ -323,6 +327,44 @@ impl BindingReport { .filter(|entity| !matches!(entity.status, BindingStatus::Bound { .. })) } + /// Parks one of *this report's* unbound entities against a fallback master + /// drawn from a catalog of the same class. + /// + /// Taking an index rather than an `EntityBinding` is the point: an entity + /// from another report — a stock-item binding, say — cannot be handed to a + /// ledger catalog, because it cannot be named here at all. The class is + /// then checked as well, so a same-shaped catalog of the wrong class is + /// refused rather than silently accepted, and the result carries the class + /// forward for anything downstream that needs to prove it. + pub fn assign_fallback( + &self, + entity_index: usize, + catalog: &MasterCatalog, + fallback_name: &str, + ) -> Result { + if catalog.class != self.class { + return Err(MasterBindingError::ClassMismatch); + } + let entity = self + .entities + .get(entity_index) + .ok_or(MasterBindingError::ClassMismatch)?; + let unresolved = entity + .unresolved() + .ok_or(MasterBindingError::FallbackNotInCatalog)?; + let fallback = catalog + .exact(fallback_name) + .ok_or(MasterBindingError::FallbackNotInCatalog)?; + Ok(FallbackBinding { + class: self.class, + position: entity.position, + source_name: entity.source_name.clone(), + fallback_name: fallback.to_string(), + retained: unresolved.unresolved_identity.clone(), + reason: unresolved.reason, + }) + } + pub fn totals(&self) -> BindingTotals { let mut totals = BindingTotals { requested: self.entities.len(), @@ -355,6 +397,7 @@ impl BindingReport { /// that already matched is not a representable state. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct FallbackBinding { + class: MasterClass, position: usize, source_name: String, fallback_name: String, @@ -363,29 +406,9 @@ pub struct FallbackBinding { } impl FallbackBinding { - /// Parks one unbound entity against a catalog-verified fallback master. - /// - /// Refuses a bound entity and refuses a fallback name that is not a current - /// catalog entry — a suspense ledger that does not exist is how one - /// engagement lost a batch. - pub fn assign( - entity: &EntityBinding, - catalog: &MasterCatalog, - fallback_name: &str, - ) -> Result { - let unresolved = entity - .unresolved() - .ok_or(MasterBindingError::FallbackNotInCatalog)?; - let fallback = catalog - .exact(fallback_name) - .ok_or(MasterBindingError::FallbackNotInCatalog)?; - Ok(Self { - position: entity.position, - source_name: entity.source_name.clone(), - fallback_name: fallback.to_string(), - retained: unresolved.unresolved_identity.clone(), - reason: unresolved.reason, - }) + /// The class of the catalog this fallback was drawn from. + pub fn class(&self) -> MasterClass { + self.class } pub fn position(&self) -> usize { 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 96e6fb10..b904e985 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 @@ -569,21 +569,23 @@ fn an_ambiguous_entity_parks_against_a_verified_fallback() { "BETA (5550000001)", "Suspense Placeholder", ]); - let binding = bind_one_name(&catalog, "PARTY 5550000001"); - let fallback = FallbackBinding::assign(&binding, &catalog, "Suspense Placeholder") + let report = bound(&catalog, &[entity("PARTY 5550000001")]); + let fallback = report + .assign_fallback(0, &catalog, "Suspense Placeholder") .expect("an unbound entity may be parked"); assert_eq!(fallback.fallback_name(), "Suspense Placeholder"); assert_eq!(fallback.source_name(), "PARTY 5550000001"); assert_eq!(fallback.reason(), UnboundReason::IdentifierConflict); assert_eq!(fallback.retained_tag(), "numeric:5550000001"); + assert_eq!(fallback.class(), MasterClass::Ledger); } #[test] fn a_bound_entity_cannot_be_parked() { let catalog = ledgers(&["Alpha Traders", "Suspense Placeholder"]); - let binding = bind_one_name(&catalog, "Alpha Traders"); + let report = bound(&catalog, &[entity("Alpha Traders")]); assert_eq!( - FallbackBinding::assign(&binding, &catalog, "Suspense Placeholder"), + report.assign_fallback(0, &catalog, "Suspense Placeholder"), Err(MasterBindingError::FallbackNotInCatalog) ); } @@ -592,13 +594,36 @@ fn a_bound_entity_cannot_be_parked() { fn a_fallback_master_that_does_not_exist_is_refused() { // A suspense ledger that was never created is how one batch was lost. let catalog = ledgers(&["Alpha Traders", "Beta Supply"]); - let binding = bind_one_name(&catalog, "Zeta Placeholder"); + let report = bound(&catalog, &[entity("Zeta Placeholder")]); assert_eq!( - FallbackBinding::assign(&binding, &catalog, "Suspense Placeholder"), + report.assign_fallback(0, &catalog, "Suspense Placeholder"), Err(MasterBindingError::FallbackNotInCatalog) ); } +#[test] +fn a_fallback_cannot_be_drawn_from_another_catalog_class_or_another_report() { + // A stock-item binding parked against a ledger catalog was a representable + // state that nothing downstream could detect. + let stock = MasterCatalog::new(MasterClass::StockItem, ["PH-01A-B00", "Scrap Placeholder"]) + .expect("valid"); + let ledger = ledgers(&["Alpha Traders", "Suspense Placeholder"]); + let stock_report = bound(&stock, &[entity("Zeta Placeholder")]); + assert_eq!( + stock_report.assign_fallback(0, &ledger, "Suspense Placeholder"), + Err(MasterBindingError::ClassMismatch) + ); + // An index outside this report cannot name another report's entity. + assert_eq!( + stock_report.assign_fallback(7, &stock, "Scrap Placeholder"), + Err(MasterBindingError::ClassMismatch) + ); + assert_eq!( + MasterBindingError::ClassMismatch.safe_reason_code(), + "master_class_mismatch" + ); +} + // --- the vocabulary is stable ---------------------------------------------- #[test] From 5dba3fe86b793486cc0aee116f27792df6739cf8 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 00:03:40 +0530 Subject: [PATCH 05/91] Address the four findings the fixes themselves generated A fix is a change and generates its own findings; the re-review of the previous commits raised four, all reproduced. An unusable ledger name in a parsed draft was skipped while the response still claimed a complete narrowing pass, so the rows that vanished were exactly the ones worth looking at; the pass is now reported unavailable. The reported candidate total took the larger of the suppressed family and the retained candidates, which under-reports when they are different masters; it is now their union. An identifier hint reached extraction without the bound applied to every other name. And the operator workflow in docs/agent/README.md still told readers to correct only near_miss rows, which now leaves a bound-but-not-exact row refused at the build. Co-Authored-By: Claude Opus 5 --- docs/agent/README.md | 16 +++++++-- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 6 ++-- .../bridge-tally-core/src/master_binding.rs | 36 +++++++++++++------ .../src/master_binding_tests.rs | 29 +++++++++++++++ src-tauri/src/source_draft/catalog.rs | 5 ++- 6 files changed, 76 insertions(+), 18 deletions(-) diff --git a/docs/agent/README.md b/docs/agent/README.md index f1ab7823..1568656e 100644 --- a/docs/agent/README.md +++ b/docs/agent/README.md @@ -189,8 +189,20 @@ type, host, licence mode, or manually imported file. 1. Call `voucher_schema` and produce a payload matching its schema. Transaction IDs are client-supplied, unique within the batch, and retained in the local import ledger. -2. Call `validate_masters` with every ledger name. Correct every `near_miss` - with the exact live spelling; Bridge never creates masters. +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. + - `near_miss` — the row is **not** bound and Bridge chose nothing. Pick from + `candidates`, each labelled with the rule that surfaced it. A single + candidate is still not a decision. Where `reason` is + `master_binding_no_discriminating_candidate`, the name reaches + `candidate_count` masters that it does not distinguish and none is listed; + use a more complete source name, or read the ledger list and choose. + - `missing` — no live ledger matched. Bridge never creates masters. 3. Call `build_import_xml` with the payload. It checks exact decimal balance, company date extent, live masters, and local journal integrity, repeats the full catalogue to reject intervening changes, then writes `/imports/.xml` and records an append-only diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 63a66d27..e7f373be 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": "49e84ae89b9fee801525eed75bdd5420f34400770354c77ee06e15449c371cde", + "compatibility_surface_sha256": "bbe8d25be2c85a829a3056aa066aa5d9e21ff453800e598f6412f76d5f6dd2cb", "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 500c05a4..3352cfda 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "6d41ba230dc4e63a53018287480c9f3c3724d4df2592b6f56c284ed4d8df8d41" + "sha256": "de8d6a702601b70fba774effa0842165393012f2cbc0a2c3f3afd7577daa049e" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -579,7 +579,7 @@ }, { "path": "src-tauri/src/source_draft/catalog.rs", - "sha256": "53cfe9d0d248af44361b7d9cb314e301cc0dba87547c54bd59ea90ae35117325" + "sha256": "ceeb38b1c6055c5219678eafc4645b57b6655594cdd457ee97f440e690f31cdc" }, { "path": "src-tauri/src/source_draft/files.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "49e84ae89b9fee801525eed75bdd5420f34400770354c77ee06e15449c371cde" + "manifest_sha256": "bbe8d25be2c85a829a3056aa066aa5d9e21ff453800e598f6412f76d5f6dd2cb" } \ No newline at end of file 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 adf04767..6ccca4ce 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -475,6 +475,9 @@ impl SourceEntity { let name = validated_name(name)?; let mut identifiers = extract_identifiers(&name)?; for hint in hints { + // A hint is caller-supplied document text like any other name, and + // must clear the same bound before anything scans or copies it. + validate_name_bounds(hint)?; let extracted = extract_identifiers(hint)?; if extracted.is_empty() { return Err(MasterBindingError::IdentifierHintUnusable); @@ -721,16 +724,16 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { &identifier_matches, ), None => { - let (candidates, prefix_family) = + let (candidates, masters_found) = collect_candidates(catalog, entity, &identifier_matches); let reason = if !candidates.is_empty() { UnboundReason::NearMiss - } else if prefix_family > MAX_PREFIX_FAMILY { + } else if masters_found > MAX_PREFIX_FAMILY { UnboundReason::NoDiscriminatingCandidate } else { UnboundReason::NoCandidate }; - unresolved_from(entity, reason, candidates, prefix_family) + unresolved_from(entity, reason, candidates, masters_found) } } }; @@ -749,21 +752,21 @@ fn unresolved_status( exact: Option, identifier_matches: &BTreeSet, ) -> BindingStatus { - let (mut candidates, prefix_family) = collect_candidates(catalog, entity, identifier_matches); + let (mut candidates, masters_found) = collect_candidates(catalog, entity, identifier_matches); if let Some(index) = exact { let name = catalog.entries[index].name.as_str(); if !candidates.iter().any(|(candidate, _)| candidate == name) { candidates.push((name.to_string(), CandidateRule::NormalizedEqual)); } } - unresolved_from(entity, reason, candidates, prefix_family) + unresolved_from(entity, reason, candidates, masters_found) } fn unresolved_from( entity: &SourceEntity, reason: UnboundReason, candidates: Vec<(String, CandidateRule)>, - prefix_family: usize, + masters_found: usize, ) -> BindingStatus { let mut ordered = candidates; ordered.sort_by(|left, right| { @@ -774,7 +777,7 @@ fn unresolved_from( }); // A suppressed family is still counted. The operator is told how many // masters the name reaches even when none of them is worth listing. - let candidate_count = ordered.len().max(prefix_family); + let candidate_count = ordered.len().max(masters_found); let candidates_truncated = candidate_count > ordered.len().min(MAX_CANDIDATES_PER_ENTITY); let candidates = ordered .into_iter() @@ -822,7 +825,7 @@ fn collect_candidates( offer(*index, CandidateRule::NormalizedEqual); } } - let mut prefix_family = 0_usize; + let mut suppressed_family: BTreeSet = BTreeSet::new(); if entity.key.chars().count() >= MIN_PREFIX_KEY_CHARS { // The key index is ordered, so both prefix directions are range or // point lookups rather than a scan of the whole catalog per entity. @@ -833,14 +836,15 @@ fn collect_candidates( .filter(|(key, _)| *key != &entity.key) .flat_map(|(_, holders)| holders.iter().copied()) .collect::>(); - prefix_family = extending.len(); // A prefix matching a whole family distinguishes nothing inside it, and // an arbitrary capped slice is worse than none: measured against live // books, that slice omitted the right master about a third of the time. - if prefix_family <= MAX_PREFIX_FAMILY { + if extending.len() <= MAX_PREFIX_FAMILY { for index in extending { offer(index, CandidateRule::CatalogPrefix); } + } else { + suppressed_family.extend(extending); } for split in MIN_PREFIX_KEY_CHARS..entity.key.len() { if !entity.key.is_char_boundary(split) { @@ -868,11 +872,21 @@ fn collect_candidates( } } + // The reported total is the union: a suppressed family and the candidates + // still worth listing are not necessarily the same masters, so taking the + // larger of the two counts would under-report what the name actually + // reaches. + let found = best + .keys() + .copied() + .chain(suppressed_family) + .collect::>() + .len(); ( best.into_iter() .map(|(index, rule)| (catalog.entries[index].name.clone(), rule)) .collect(), - prefix_family, + found, ) } 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 b904e985..077e4957 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 @@ -486,6 +486,35 @@ fn a_family_within_the_bound_is_still_listed_in_full() { assert!(!unresolved.candidates_truncated); } +#[test] +fn the_reported_count_is_the_union_of_suppressed_and_listed_candidates() { + // A suppressed family and the candidates still worth listing are not the + // same masters. Reporting the larger of the two counts under-reports what + // the name actually reaches, and candidate_count is promised as the total + // found before truncation. + let mut names = (0..MAX_PREFIX_FAMILY + 5) + .map(|index| format!("Alpha Beta {index:02}")) + .collect::>(); + names.push("Alpha".to_string()); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + + let binding = bind_one_name(&catalog, "Alpha Beta"); + let unresolved = binding.unresolved().expect("unbound"); + // The shorter master is still listed; the family behind it is not. + assert_eq!(candidate_names(&binding), ["Alpha"]); + assert_eq!(unresolved.candidate_count, MAX_PREFIX_FAMILY + 6); + assert!(unresolved.candidates_truncated); +} + +#[test] +fn an_identifier_hint_is_bounded_before_anything_scans_it() { + let huge = "5".repeat(MAX_NAME_CHARS + 1); + assert_eq!( + SourceEntity::with_identifier_hints(0, "Alpha Traders", [huge.as_str()]), + Err(MasterBindingError::NameTooLong) + ); +} + #[test] fn candidate_order_is_rule_then_name_and_never_a_ranking() { let catalog = ledgers(&[ diff --git a/src-tauri/src/source_draft/catalog.rs b/src-tauri/src/source_draft/catalog.rs index ce2f3f38..8c72b34d 100644 --- a/src-tauri/src/source_draft/catalog.rs +++ b/src-tauri/src/source_draft/catalog.rs @@ -148,8 +148,11 @@ fn source_entry_bindings( let mut entities = Vec::new(); for voucher in &source.vouchers { for entry in &voucher.entries { + // Dropping an unusable entry would return fewer bindings than the + // source has rows while still claiming completeness, and the row + // that vanished is exactly the one an operator needs to look at. let Ok(entity) = SourceEntity::new(entities.len(), &entry.ledger) else { - continue; + return (Vec::new(), "unavailable"); }; located.push((voucher.position, entry.position)); entities.push(entity); From acef951d5aab4088ed25f7b7602e9081d544e21b Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 00:11:17 +0530 Subject: [PATCH 06/91] Keep period labels out of code identifiers, and drop a quadratic scan Two further findings from the re-review, both reproduced. `Purchases FY25` and `Sales FY25` both yielded the code identifier `FY25`, so identifier-first matching bound the source to whichever existed before it ever compared the names. A fiscal-period label identifies a period, not a party, and is now excluded by shape; the minimum code length also rises from four to six, since a four-character mixed token is weak evidence of identity and the failure mode here is money against the wrong party. Re-measured against the same 470 live ledger names: no change to the distribution, so the tightening costs nothing observed. Candidate collection recounted every prefix from the start of the name, making it quadratic in a field the source parser lets reach 4 KiB. It now carries the character count forward in one pass. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 41 +++++++++++++++---- .../src/master_binding_tests.rs | 24 +++++++++++ 4 files changed, 59 insertions(+), 12 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index e7f373be..641e73b5 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": "bbe8d25be2c85a829a3056aa066aa5d9e21ff453800e598f6412f76d5f6dd2cb", + "compatibility_surface_sha256": "eadfb92768999ec0081569cd3aeb292361e0f0066449ab7ac423650099553af9", "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 3352cfda..77521646 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "de8d6a702601b70fba774effa0842165393012f2cbc0a2c3f3afd7577daa049e" + "sha256": "9c9ce0ddb6f8773c810616526fb05440e0d83b2fe82fd2ca85d07d4557862c10" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "bbe8d25be2c85a829a3056aa066aa5d9e21ff453800e598f6412f76d5f6dd2cb" + "manifest_sha256": "eadfb92768999ec0081569cd3aeb292361e0f0066449ab7ac423650099553af9" } \ No newline at end of file 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 6ccca4ce..8550f51b 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -38,8 +38,10 @@ pub const MAX_IDENTIFIERS_PER_NAME: usize = 32; /// an account number and a customer code all clear it. pub const MIN_NUMERIC_IDENTIFIER_DIGITS: usize = 8; /// Alphanumeric characters a mixed letter-and-digit token needs before it is -/// treated as a code identifier. -pub const MIN_CODE_IDENTIFIER_CHARS: usize = 4; +/// treated as a code identifier. Six rather than four: a four-character mixed +/// token is weak evidence of identity, and the failure mode of a wrong +/// identifier is money against the wrong party. +pub const MIN_CODE_IDENTIFIER_CHARS: usize = 6; /// Digits a code identifier needs alongside at least one letter. pub const MIN_CODE_IDENTIFIER_DIGITS: usize = 2; /// Shortest comparison key that may take part in a prefix near-miss. @@ -846,15 +848,14 @@ fn collect_candidates( } else { suppressed_family.extend(extending); } - for split in MIN_PREFIX_KEY_CHARS..entity.key.len() { - if !entity.key.is_char_boundary(split) { + // One pass, carrying the character count forward. Recomputing + // `chars().count()` per prefix made this quadratic in the name length, + // and the source parser admits 4 KiB fields. + for (characters, (split, _)) in entity.key.char_indices().enumerate() { + if characters < MIN_PREFIX_KEY_CHARS { continue; } - let prefix = &entity.key[..split]; - if prefix.chars().count() < MIN_PREFIX_KEY_CHARS { - continue; - } - if let Some(holders) = catalog.by_key.get(prefix) { + if let Some(holders) = catalog.by_key.get(&entity.key[..split]) { for index in holders { offer(*index, CandidateRule::SourcePrefix); } @@ -969,6 +970,7 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro if canonical.len() >= MIN_CODE_IDENTIFIER_CHARS && digits >= MIN_CODE_IDENTIFIER_DIGITS && letters >= 1 + && !is_period_label(&canonical) { identifiers.insert(Identifier { kind: IdentifierKind::Code, @@ -995,6 +997,27 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro Ok(identifiers.into_iter().collect()) } +/// A fiscal-period label identifies a period, not a party or an item. Two +/// unrelated ledgers routinely share one — `Purchases FY2025` and +/// `Sales FY2025` — and identifier-first matching would bind the source to +/// whichever exists before it ever compared the names. +/// +/// This is a shape rule, not a vocabulary: it recognizes a short alphabetic +/// period marker followed only by digits, and like every other exclusion here +/// it can only make a bind *less* likely. +fn is_period_label(canonical: &str) -> bool { + let letters = canonical + .chars() + .take_while(|character| character.is_ascii_alphabetic()) + .collect::(); + let rest = &canonical[letters.len()..]; + matches!( + letters.as_str(), + "FY" | "AY" | "CY" | "Q" | "H" | "P" | "PER" | "FYE" + ) && !rest.is_empty() + && rest.chars().all(|character| character.is_ascii_digit()) +} + /// An eight-digit run that reads as a calendar date in any order this project /// admits is a date, not an identifier. Recognizing only `YYYYMMDD` left /// `01012026` binding a source to an unrelated master that shares its period 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 077e4957..fe7dbbce 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 @@ -410,6 +410,30 @@ fn digits_inside_a_mixed_code_are_not_also_a_standalone_identifier() { ); } +#[test] +fn a_fiscal_period_label_is_not_an_identity_bearing_code() { + // Two unrelated ledgers routinely share a period label. Identifier-first + // matching would otherwise bind the source to whichever one exists before + // it ever compared the names. + for label in ["FY25", "FY2025", "AY2026", "Q3", "H2", "PER2026"] { + assert!( + entity(&format!("Purchases {label}")) + .identifiers() + .is_empty(), + "{label} was treated as a code identifier" + ); + } + let catalog = ledgers(&["Sales FY2025", "Beta Supply"]); + let binding = bind_one_name(&catalog, "Purchases FY2025"); + assert_eq!( + binding.bound_name(), + None, + "a shared period label must not bind two unrelated ledgers" + ); + // A genuine identity-bearing code still is one. + assert_eq!(entity("Item PH01AB00").identifiers().len(), 1); +} + #[test] fn an_eight_digit_date_in_any_admitted_order_is_not_an_identifier() { for date in ["20260910", "01012026", "31122026", "12312026"] { From aa9f4fb2ed63bc642c3b70bb83f12d3b03a1e80a Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 00:43:09 +0530 Subject: [PATCH 07/91] Seed identifier-bearing ledgers, and fix what they immediately exposed No book on the instance carried an embedded identifier: across 470 live ledger names from all 16 loaded companies, zero yielded a numeric identifier and exactly one a code identifier. The rule that separates this from fuzzy matching had no live coverage at all. Ten `MB ` ledgers now exist in BRIDGE CORPUS OPENING, parented to Suspense A/c so no receivable, payable or ageing measurement moves, and documented in TEST_CORPUS.md section 9 with the import method and the company-choice reasoning. BRIDGE PROBE B SANDBOX was rejected as the target despite the manufacturing precedent: it shares a GUID with a second loaded company and Bridge's own reads refuse it as company_identity_ambiguous. Within minutes the pair sharing one identifier exposed a defect no fabricated fixture had produced. A byte-exact request for a ledger whose embedded number is shared with another was refused as IdentifierConflict, making that ledger permanently unimportable, since the write gate admits exact only. Byte equality with an observed master name is now decisive: it names exactly one master, and an ambiguous identifier does not undermine it. Only a decisive identifier pointing elsewhere still outranks an exact name, and that stays a reported conflict. Re-measured over 485 live names, 2,330 cases: identifier binds 3 -> 11, every uppercase mutation now binds, and where candidates are listed the right master is present in 434 of 434 rows at a median list length of 2. Co-Authored-By: Claude Opus 5 --- docs/tally/TEST_CORPUS.md | 44 ++++++++++++++ .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 60 ++++++++++--------- .../src/master_binding_tests.rs | 40 +++++++++++++ 5 files changed, 120 insertions(+), 30 deletions(-) diff --git a/docs/tally/TEST_CORPUS.md b/docs/tally/TEST_CORPUS.md index 3476fd59..199b2d15 100644 --- a/docs/tally/TEST_CORPUS.md +++ b/docs/tally/TEST_CORPUS.md @@ -349,6 +349,50 @@ bytes, so it cannot support any claim about the exact bytes a real instance rece --- +## 9. Master-binding ledgers in `BRIDGE CORPUS OPENING` + +**Added 2026-09-10.** Ten ledgers prefixed `MB `, seeded so the master-binding +identifier rule has live coverage. Before this, **no book on either instance carried an +embedded identifier**: across 470 live ledger names read from all 16 loaded companies, +zero yielded a numeric identifier and exactly one yielded a code identifier. The rule that +distinguishes `bridge_tally_core::master_binding` from fuzzy matching was qualified by +fabricated data alone. + +| ledger | what it exercises | +| --- | --- | +| `MB PILOT ALPHA (5550001001)` | a unique embedded number | +| `MB PARTY BETA (5550001002)`, `MB PARTY GAMMA (5550001003)` | the same, for name-vs-identifier cases | +| `MB PARTY DELTA (5550001009)`, `MB PARTY EPSILON (5550001009)` | **two masters sharing one identifier** — must refuse, never bind | +| `MB ITEM PH01AB00` | a code identifier, matched across punctuation | +| `MB PURCHASES FY2025`, `MB SALES FY2025` | a shared fiscal-period label that must **not** be treated as an identifier | +| `MB TRADING COMPANY`, `MB TRADING COMPANY LIMITED` | a truncation / near-duplicate pair | + +**Chosen company.** `BRIDGE CORPUS OPENING` (GUID `915d42f8-42ae-4b03-8291-55f596e3a2ea`), +because it verifies as a single identity tuple and had only eight ledgers. **Not** +`BRIDGE PROBE B SANDBOX`, despite that being where corpus manufacturing was first proven: +it and `BRIDGE PROBE B SANDBOX - (from 1-Apr-26)` share one GUID, and Bridge's own read +path refuses that company with `company_identity_ambiguous`. Do not write to it by name. + +**Blast radius, deliberately small.** All ten are parented to `Suspense A/c`, which is not +a party group, so receivable/payable and ageing measurements on this book are unaffected. +They carry no opening balance and no vouchers. Every name is prefixed `MB `, so they are +trivially identifiable and removable. Master `AlterID` for this company did move; anything +pinning `ALTMSTID` for `BRIDGE CORPUS OPENING` predates 2026-09-10. + +**Import method.** `REPORTNAME=All Masters`, `ACTION="Create"`, one pilot ledger sent and +verified in the intended company *and confirmed absent from a guard company* before the +remaining nine. Counters were `CREATED=10, ALTERED=0, ERRORS=0`, and every name was +confirmed by a readback of the ledger list — counters alone prove nothing, since Tally +rewrites imports silently. **Do not re-send the create file:** an identical `Create` is a +silent `Alter` that overwrites. + +**What it found within minutes.** The `DELTA`/`EPSILON` pair exposed a defect no fabricated +fixture had produced: a *byte-exact* request for `MB PARTY DELTA (5550001009)` was being +refused as `IdentifierConflict`, because the number in its name is shared. That made the +ledger permanently unimportable, since the write gate admits `exact` only. Byte equality is +now decisive over an ambiguous identifier; only a *decisive* identifier pointing elsewhere +outranks an exact name. + ## 6. Changelog | Date | Change | diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 641e73b5..3dcd6097 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": "eadfb92768999ec0081569cd3aeb292361e0f0066449ab7ac423650099553af9", + "compatibility_surface_sha256": "e9640c63aaf927cf0b55b9ca5d8fc5a306df0134449f13c54d4e18471b6a98b7", "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 77521646..6d75b9b5 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "9c9ce0ddb6f8773c810616526fb05440e0d83b2fe82fd2ca85d07d4557862c10" + "sha256": "3cbb8f43d5f095e72819df7b9a997fdadbb9743e95ace24d8bd61f9f722842a1" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "eadfb92768999ec0081569cd3aeb292361e0f0066449ab7ac423650099553af9" + "manifest_sha256": "e9640c63aaf927cf0b55b9ca5d8fc5a306df0134449f13c54d4e18471b6a98b7" } \ No newline at end of file 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 8550f51b..7330e628 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -674,44 +674,50 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { // An identifier shared by two masters, and an entity whose identifiers // reach two masters, are the same refusal: the operator has a naming // collision to see, and neither case licenses a choice. - let status = if identifier_conflict || identifier_matches.len() > 1 { + // Byte equality with an observed master name is the strongest evidence + // there is, and it names exactly one master. An identifier that happens to + // be ambiguous does not undermine it: refusing here would make a ledger + // whose embedded number is shared with another permanently unimportable — + // the same dead end that reporting `Identifier` for an exact name created. + // Only a *decisive* identifier pointing elsewhere outranks a byte-exact + // name, and that stays a reported conflict rather than a silent choice. + // + // Found by seeding two live ledgers that share an embedded number. No + // fabricated fixture had produced the combination. + let identifier_points_elsewhere = !identifier_conflict + && identifier_matches.len() == 1 + && exact.is_some_and(|index| !identifier_matches.contains(&index)); + let status = if identifier_points_elsewhere { unresolved_status( catalog, entity, - UnboundReason::IdentifierConflict, + UnboundReason::IdentifierNameConflict, exact, &identifier_matches, ) - } else if let Some(matched) = identifier_matches.iter().copied().next() { - // An identifier pointing at one master while the name exactly names - // another is a disagreement between two strong signals; it is shown, - // not silently decided in the identifier's favour. - match exact { - // Two strong signals disagreeing is shown, not settled. - Some(index) if index != matched => unresolved_status( - catalog, - entity, - UnboundReason::IdentifierNameConflict, - exact, - &identifier_matches, - ), - // They agree. Report the stronger, byte-level fact: the write gate - // admits `ExactName` only, and reporting `Identifier` here made - // every ledger carrying a number permanently unimportable. - Some(_) => BindingStatus::Bound { - catalog_name: catalog.entries[matched].name.clone(), - basis: BindingBasis::ExactName, - }, - None => BindingStatus::Bound { - catalog_name: catalog.entries[matched].name.clone(), - basis: BindingBasis::Identifier, - }, - } } else if let Some(index) = exact { BindingStatus::Bound { catalog_name: catalog.entries[index].name.clone(), basis: BindingBasis::ExactName, } + } else if identifier_conflict || identifier_matches.len() > 1 { + // An identifier shared by two masters, and an entity whose identifiers + // reach two masters, are the same refusal: the operator has a naming + // collision to see, and neither case licenses a choice. + unresolved_status( + catalog, + entity, + UnboundReason::IdentifierConflict, + exact, + &identifier_matches, + ) + } else if let Some(matched) = identifier_matches.iter().copied().next() { + // A decisive identifier, with no byte-exact name to outrank it. This is + // the rule that decided the case fuzzy matching got wrong. + BindingStatus::Bound { + catalog_name: catalog.entries[matched].name.clone(), + basis: BindingBasis::Identifier, + } } else { match catalog.by_key.get(&entity.key).map(Vec::as_slice) { Some([index]) => BindingStatus::Bound { 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 fe7dbbce..901e25d7 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 @@ -370,6 +370,46 @@ fn a_byte_exact_name_carrying_a_number_is_reported_exact_not_identifier() { ); } +#[test] +fn a_byte_exact_name_binds_even_when_its_identifier_is_shared() { + // Found by seeding two live ledgers that share an embedded number, which no + // fabricated fixture had combined. Refusing a name that exactly names one + // master makes that ledger permanently unimportable. + let catalog = ledgers(&[ + "MB PARTY DELTA (5550001009)", + "MB PARTY EPSILON (5550001009)", + "Beta Supply", + ]); + let binding = bind_one_name(&catalog, "MB PARTY DELTA (5550001009)"); + assert_eq!( + binding.status, + BindingStatus::Bound { + catalog_name: "MB PARTY DELTA (5550001009)".to_string(), + basis: BindingBasis::ExactName, + } + ); + // The shared identifier alone, with no exact name, still refuses. + let source = + SourceEntity::with_identifier_hints(0, "SOME PARTY", ["5550001009"]).expect("valid"); + let report = bound(&catalog, &[source]); + assert_eq!( + report.entities()[0].unresolved().expect("unbound").reason, + UnboundReason::IdentifierConflict + ); +} + +#[test] +fn a_decisive_identifier_pointing_elsewhere_still_outranks_a_byte_exact_name() { + let catalog = ledgers(&["ALPHA (5550000001)", "BETA Supply"]); + let source = + SourceEntity::with_identifier_hints(0, "BETA Supply", ["5550000001"]).expect("valid"); + let report = bound(&catalog, &[source]); + assert_eq!( + report.entities()[0].unresolved().expect("unbound").reason, + UnboundReason::IdentifierNameConflict + ); +} + #[test] fn a_trailing_space_never_claims_byte_equality() { // `Bank ` against live `Bank` must not report exact: the import file would From b87c8a3403d7dd2ca1bbacc96c9429f6ff8064f8 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 00:54:28 +0530 Subject: [PATCH 08/91] Recognize period labels by shape, and bound the report at its source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 6 +- .../bridge-tally-core/src/master_binding.rs | 118 +++++++++++++----- .../src/master_binding_tests.rs | 45 ++++++- src-tauri/src/source_draft/catalog.rs | 29 ++--- 5 files changed, 141 insertions(+), 59 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 3dcd6097..becfab61 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": "e9640c63aaf927cf0b55b9ca5d8fc5a306df0134449f13c54d4e18471b6a98b7", + "compatibility_surface_sha256": "acd1f802f722a8b8638ab8dafe5b708c55c2cc7469b724b9e21ab11df239b88b", "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 6d75b9b5..02c7b767 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "3cbb8f43d5f095e72819df7b9a997fdadbb9743e95ace24d8bd61f9f722842a1" + "sha256": "ae31c5066579a46ad36c3bcffdb43cec4ac95e48ffa26c694c19aa871000a9c2" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -579,7 +579,7 @@ }, { "path": "src-tauri/src/source_draft/catalog.rs", - "sha256": "ceeb38b1c6055c5219678eafc4645b57b6655594cdd457ee97f440e690f31cdc" + "sha256": "2ab349ed9b64ecea7442d604fc8e75c7824daf3cd5945e5c199c68c7097141ed" }, { "path": "src-tauri/src/source_draft/files.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "e9640c63aaf927cf0b55b9ca5d8fc5a306df0134449f13c54d4e18471b6a98b7" + "manifest_sha256": "acd1f802f722a8b8638ab8dafe5b708c55c2cc7469b724b9e21ab11df239b88b" } \ No newline at end of file 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 7330e628..0c1bdd6c 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -30,6 +30,15 @@ pub const MAX_SOURCE_ENTITIES: usize = 40_000; pub const MAX_NAME_CHARS: usize = 16_384; /// Most candidates retained per unbound entity. pub const MAX_CANDIDATES_PER_ENTITY: usize = 25; +/// Total candidate-name bytes one report may allocate, across all entities. +/// +/// A per-entity cap does not bound a report: a draft the source parser admits +/// can carry tens of thousands of entries that each list 25 long names, and the +/// clones exist the moment the report is built. A consumer capping its own copy +/// afterwards bounds only the second copy. This is spent in entity order; +/// entities past it report their true `candidate_count` with no candidates +/// listed and truncation flagged. +pub const MAX_REPORT_CANDIDATE_BYTES: usize = 256 * 1024; /// Most identifiers one name may carry. Exceeding it is refused, never /// truncated. pub const MAX_IDENTIFIERS_PER_NAME: usize = 32; @@ -38,12 +47,18 @@ pub const MAX_IDENTIFIERS_PER_NAME: usize = 32; /// an account number and a customer code all clear it. pub const MIN_NUMERIC_IDENTIFIER_DIGITS: usize = 8; /// Alphanumeric characters a mixed letter-and-digit token needs before it is -/// treated as a code identifier. Six rather than four: a four-character mixed -/// token is weak evidence of identity, and the failure mode of a wrong -/// identifier is money against the wrong party. -pub const MIN_CODE_IDENTIFIER_CHARS: usize = 6; -/// Digits a code identifier needs alongside at least one letter. -pub const MIN_CODE_IDENTIFIER_DIGITS: usize = 2; +/// treated as a code identifier. +/// +/// Eight, raised twice under review. Enumerating the period shapes that must +/// not be identifiers — `FY25`, then `APR2025`, then `2025Q1` — is a losing +/// game, and each miss binds two unrelated ledgers that merely share a period. +/// Requiring real length is the rule that does not depend on having thought of +/// every label: a part number or registration code clears it, and a period +/// label does not. Measured against 485 live ledger names, exactly one yields a +/// code identifier at all, so this costs nothing observed. +pub const MIN_CODE_IDENTIFIER_CHARS: usize = 8; +/// Digits a code identifier needs alongside at least two letters. +pub const MIN_CODE_IDENTIFIER_DIGITS: usize = 3; /// Shortest comparison key that may take part in a prefix near-miss. pub const MIN_PREFIX_KEY_CHARS: usize = 3; /// Shortest token that may take part in a shared-token near-miss. @@ -645,16 +660,17 @@ pub fn bind( if entities.len() > MAX_SOURCE_ENTITIES { return Err(MasterBindingError::TooManySourceEntities); } + let mut budget = MAX_REPORT_CANDIDATE_BYTES; Ok(BindingReport { class: catalog.class, entities: entities .iter() - .map(|entity| bind_one(catalog, entity)) + .map(|entity| bind_one(catalog, entity, &mut budget)) .collect(), }) } -fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { +fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity, budget: &mut usize) -> EntityBinding { let exact = catalog.by_name.get(&entity.name).copied(); // Rule one: the identifier is the key, the name is a hint. A name @@ -694,6 +710,7 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { UnboundReason::IdentifierNameConflict, exact, &identifier_matches, + budget, ) } else if let Some(index) = exact { BindingStatus::Bound { @@ -710,6 +727,7 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { UnboundReason::IdentifierConflict, exact, &identifier_matches, + budget, ) } else if let Some(matched) = identifier_matches.iter().copied().next() { // A decisive identifier, with no byte-exact name to outrank it. This is @@ -730,6 +748,7 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { UnboundReason::NameAmbiguous, exact, &identifier_matches, + budget, ), None => { let (candidates, masters_found) = @@ -741,7 +760,7 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity) -> EntityBinding { } else { UnboundReason::NoCandidate }; - unresolved_from(entity, reason, candidates, masters_found) + unresolved_from(entity, reason, candidates, masters_found, budget) } } }; @@ -759,6 +778,7 @@ fn unresolved_status( reason: UnboundReason, exact: Option, identifier_matches: &BTreeSet, + budget: &mut usize, ) -> BindingStatus { let (mut candidates, masters_found) = collect_candidates(catalog, entity, identifier_matches); if let Some(index) = exact { @@ -767,7 +787,7 @@ fn unresolved_status( candidates.push((name.to_string(), CandidateRule::NormalizedEqual)); } } - unresolved_from(entity, reason, candidates, masters_found) + unresolved_from(entity, reason, candidates, masters_found, budget) } fn unresolved_from( @@ -775,6 +795,7 @@ fn unresolved_from( reason: UnboundReason, candidates: Vec<(String, CandidateRule)>, masters_found: usize, + budget: &mut usize, ) -> BindingStatus { let mut ordered = candidates; ordered.sort_by(|left, right| { @@ -786,18 +807,21 @@ fn unresolved_from( // A suppressed family is still counted. The operator is told how many // masters the name reaches even when none of them is worth listing. let candidate_count = ordered.len().max(masters_found); - let candidates_truncated = candidate_count > ordered.len().min(MAX_CANDIDATES_PER_ENTITY); + let listed = ordered.len().min(MAX_CANDIDATES_PER_ENTITY); let candidates = ordered .into_iter() .take(MAX_CANDIDATES_PER_ENTITY) - .map(|(catalog_name, rule)| Candidate { catalog_name, rule }) - .collect(); + .map_while(|(catalog_name, rule)| { + *budget = budget.checked_sub(catalog_name.len())?; + Some(Candidate { catalog_name, rule }) + }) + .collect::>(); let unresolved = Unresolved { reason, unresolved_identity: entity.identifiers.clone(), - candidates, candidate_count, - candidates_truncated, + candidates_truncated: candidate_count > listed || candidates.len() < listed, + candidates, }; if matches!(reason, UnboundReason::NoCandidate) { BindingStatus::Unmatched(unresolved) @@ -975,7 +999,7 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro let letters = canonical.chars().filter(char::is_ascii_alphabetic).count(); if canonical.len() >= MIN_CODE_IDENTIFIER_CHARS && digits >= MIN_CODE_IDENTIFIER_DIGITS - && letters >= 1 + && letters >= 2 && !is_period_label(&canonical) { identifiers.insert(Identifier { @@ -1003,28 +1027,54 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro Ok(identifiers.into_iter().collect()) } -/// A fiscal-period label identifies a period, not a party or an item. Two -/// unrelated ledgers routinely share one — `Purchases FY2025` and -/// `Sales FY2025` — and identifier-first matching would bind the source to -/// whichever exists before it ever compared the names. +/// A period label identifies a period, not a party or an item. Two unrelated +/// ledgers routinely share one — `Purchases FY2025` and `Sales FY2025`, +/// `Purchases APR2025` and `Sales APR2025` — and identifier-first matching +/// would bind the source to whichever exists before it compared the names. +/// +/// Recognized by *shape* rather than by a vocabulary of prefixes, because a +/// list of prefixes kept missing one more spelling: every run in the token is +/// either a short alphabetic marker or a number that reads as a year or a +/// small ordinal, and there are at most three runs. `FY2025`, `APR2025`, +/// `2025Q1` and `Q3` all match; `PH01AB00` and `AB12345678` do not. /// -/// This is a shape rule, not a vocabulary: it recognizes a short alphabetic -/// period marker followed only by digits, and like every other exclusion here -/// it can only make a bind *less* likely. +/// Like every exclusion here it can only make a bind *less* likely. fn is_period_label(canonical: &str) -> bool { - let letters = canonical - .chars() - .take_while(|character| character.is_ascii_alphabetic()) - .collect::(); - let rest = &canonical[letters.len()..]; - matches!( - letters.as_str(), - "FY" | "AY" | "CY" | "Q" | "H" | "P" | "PER" | "FYE" - ) && !rest.is_empty() - && rest.chars().all(|character| character.is_ascii_digit()) + let mut runs = 0_usize; + let mut has_period_number = false; + let mut rest = canonical; + while !rest.is_empty() { + runs += 1; + if runs > 3 { + return false; + } + let alphabetic = rest.starts_with(|character: char| character.is_ascii_alphabetic()); + let split = rest + .find(|character: char| character.is_ascii_alphabetic() != alphabetic) + .unwrap_or(rest.len()); + let (run, tail) = rest.split_at(split); + rest = tail; + if alphabetic { + if run.len() > 4 { + return false; + } + } else { + let value = run.parse::().unwrap_or(u32::MAX); + let reads_as_period = match run.len() { + 1 | 2 => (1..=99).contains(&value), + 4 => (1900..=2199).contains(&value), + _ => false, + }; + if !reads_as_period { + return false; + } + has_period_number = true; + } + } + has_period_number } -/// An eight-digit run that reads as a calendar date in any order this project +/// An eight-digit run that reads as a calendar date in any order this project/// An eight-digit run that reads as a calendar date in any order this project /// admits is a date, not an identifier. Recognizing only `YYYYMMDD` left /// `01012026` binding a source to an unrelated master that shares its period /// label. Being generous here can only make a bind *less* likely, which is the 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 901e25d7..68e6b842 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 @@ -455,7 +455,9 @@ fn a_fiscal_period_label_is_not_an_identity_bearing_code() { // Two unrelated ledgers routinely share a period label. Identifier-first // matching would otherwise bind the source to whichever one exists before // it ever compared the names. - for label in ["FY25", "FY2025", "AY2026", "Q3", "H2", "PER2026"] { + for label in [ + "FY25", "FY2025", "AY2026", "Q3", "H2", "PER2026", "APR2025", "2025Q1", "MAR26", "H12026", + ] { assert!( entity(&format!("Purchases {label}")) .identifiers() @@ -474,6 +476,47 @@ fn a_fiscal_period_label_is_not_an_identity_bearing_code() { assert_eq!(entity("Item PH01AB00").identifiers().len(), 1); } +#[test] +fn a_report_bounds_its_own_candidate_allocation() { + // A per-entity cap does not bound a report: the clones exist the moment it + // is built, and a consumer capping its own copy afterwards bounds only the + // copy. The budget is spent in entity order; entities past it keep their + // true count and flag truncation. + let long = "Z".repeat(400); + let names = (0..30) + .map(|index| format!("SHARED PREFIX {index:03} {long}")) + .collect::>(); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + let entities = (0..2_000) + .map(|position| SourceEntity::new(position, "SHARED PREFIX 001").expect("valid")) + .collect::>(); + let report = bound(&catalog, &entities); + let listed: usize = report + .unbound() + .filter_map(|entity| entity.unresolved()) + .map(|unresolved| { + unresolved + .candidates + .iter() + .map(|candidate| candidate.catalog_name.len()) + .sum::() + }) + .sum(); + assert!( + listed <= MAX_REPORT_CANDIDATE_BYTES, + "report allocated {listed} candidate bytes" + ); + let starved = report + .unbound() + .filter_map(|entity| entity.unresolved()) + .filter(|unresolved| unresolved.candidates.is_empty()) + .collect::>(); + assert!(!starved.is_empty(), "the budget must actually bite here"); + assert!(starved + .iter() + .all(|unresolved| unresolved.candidate_count > 0 && unresolved.candidates_truncated)); +} + #[test] fn an_eight_digit_date_in_any_admitted_order_is_not_an_identifier() { for date in ["20260910", "01012026", "31122026", "12312026"] { diff --git a/src-tauri/src/source_draft/catalog.rs b/src-tauri/src/source_draft/catalog.rs index 8c72b34d..dd3cae80 100644 --- a/src-tauri/src/source_draft/catalog.rs +++ b/src-tauri/src/source_draft/catalog.rs @@ -130,9 +130,6 @@ pub(super) struct CatalogApplySnapshot { pub(super) catalog: StandardLedgerCatalog, } -/// Total candidate-name bytes one catalogue-load response may carry. -const MAX_BINDING_CANDIDATE_BYTES: usize = 256 * 1024; - /// 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 @@ -163,11 +160,9 @@ fn source_entry_bindings( // a failed pass read exactly like a source that narrowed to nothing. return (Vec::new(), "unavailable"); }; - // Candidate names are cloned per entry, so a large draft whose entries all - // share a prefix could otherwise build tens of megabytes of duplicate text - // before serialization. The budget is spent in source order and every entry - // still reports its true count. - let mut budget = MAX_BINDING_CANDIDATE_BYTES; + // The report itself is bounded by `MAX_REPORT_CANDIDATE_BYTES`, so this + // path no longer needs a second budget of its own: capping the copy left + // the original allocation unbounded, which was the actual stall risk. let bindings = report .entities() .iter() @@ -188,24 +183,18 @@ fn source_entry_bindings( candidates_truncated: false, }, BindingStatus::Ambiguous(unresolved) | BindingStatus::Unmatched(unresolved) => { - let mut candidates = Vec::new(); - for candidate in &unresolved.candidates { - let Some(remaining) = budget.checked_sub(candidate.catalog_name.len()) - else { - break; - }; - budget = remaining; - candidates.push(candidate.catalog_name.clone()); - } SourceDraftCatalogBinding { row_position, entry_position, bound_target: None, bound_basis: None, unbound_reason: Some(unresolved.reason.safe_reason_code()), - candidates_truncated: unresolved.candidates_truncated - || candidates.len() < unresolved.candidates.len(), - candidates, + candidates_truncated: unresolved.candidates_truncated, + candidates: unresolved + .candidates + .iter() + .map(|candidate| candidate.catalog_name.clone()) + .collect(), candidate_count: unresolved.candidate_count, } } From 03280f2b5aa9d8091fa1d2f49991059a2da86209 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:26:53 +0530 Subject: [PATCH 09/91] Make the comparison key an explicit contract point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/tally/compatibility/compatibility-matrix.json | 2 +- .../tally/compatibility/compatibility-surface.json | 4 ++-- .../crates/bridge-tally-core/src/master_binding.rs | 14 +++++++++++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index becfab61..e8d50969 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": "acd1f802f722a8b8638ab8dafe5b708c55c2cc7469b724b9e21ab11df239b88b", + "compatibility_surface_sha256": "f86d7446936b1f953e18912ea1c44a7e379ef32df065b3846fb9a5a2d78e314c", "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 02c7b767..e05ad488 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "ae31c5066579a46ad36c3bcffdb43cec4ac95e48ffa26c694c19aa871000a9c2" + "sha256": "f90c94916acfe7c581d020765062f1906564daab1b745a700a221633e4f60ccb" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "acd1f802f722a8b8638ab8dafe5b708c55c2cc7469b724b9e21ab11df239b88b" + "manifest_sha256": "f86d7446936b1f953e18912ea1c44a7e379ef32df065b3846fb9a5a2d78e314c" } \ No newline at end of file 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 0c1bdd6c..2fccae8e 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -952,7 +952,19 @@ fn validate_name_bounds(value: &str) -> Result<(), MasterBindingError> { /// Folds the punctuation an operator happened to type: NFC-equivalent dash and /// quote variants become ASCII, case is lowered, whitespace runs collapse. /// Nothing else is folded — no stemming, no transliteration, no vowel removal. -fn comparison_key(value: &str) -> String { +/// +/// **This is a contract, not an implementation detail.** Anything in this crate +/// that decides whether two operator-typed strings are "the same" — master +/// names here, and voucher numbers or voucher-type names elsewhere — must fold +/// through this one function. A second, subtly different normaliser is exactly +/// the divergence ADR 0016 exists to end, and it would diverge silently: +/// the two agree on every name anyone tests by hand and disagree on the +/// punctuation nobody thinks to try. +/// +/// The corollary is that changing what this folds changes every consumer's +/// notion of sameness at once. Widen it only with the same care as a wire +/// format, and never to make one caller's case pass. +pub(crate) fn comparison_key(value: &str) -> String { value .nfc() .flat_map(|character| match character { From eaf3dd7ae81cd4576305e4d9ef357b19c6d52994 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:48:40 +0530 Subject: [PATCH 10/91] Name the channel a parked identity must travel in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- src-tauri/crates/bridge-tally-core/src/master_binding.rs | 8 ++++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index e8d50969..5ac5de99 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": "f86d7446936b1f953e18912ea1c44a7e379ef32df065b3846fb9a5a2d78e314c", + "compatibility_surface_sha256": "b3c3b808259cdfb7530d489ee389b760708313de9e49c9ed019bf7d22dd0856d", "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 e05ad488..fdcfed42 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "f90c94916acfe7c581d020765062f1906564daab1b745a700a221633e4f60ccb" + "sha256": "adc4318c06e337bff245014f3f585aaad47cc4d6c6a5988c4221b656b51c0b62" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "f86d7446936b1f953e18912ea1c44a7e379ef32df065b3846fb9a5a2d78e314c" + "manifest_sha256": "b3c3b808259cdfb7530d489ee389b760708313de9e49c9ed019bf7d22dd0856d" } \ No newline at end of file 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 2fccae8e..6a478403 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -254,6 +254,14 @@ pub struct Unresolved { /// Identifiers extracted from the source name and from caller hints, /// retained so a fallback posting can be reallocated later without /// re-reading the source document. + /// + /// **This must travel in a channel that survives a read back — the + /// narration.** A client-supplied `REMOTEID` is not it: Tally overwrites + /// the attribute with its own value, so a key written there cannot be + /// observed afterwards and cannot identify what to reallocate + /// (`docs/tally/IMPLEMENTATION_GUIDE.md` §3.3a, fourth property, verified). + /// A parked amount whose identity went into a write-only field is + /// unreallocatable, and nothing about the write would say so. pub unresolved_identity: Vec, pub candidates: Vec, /// Candidates found before truncation. From 46a157d698a3e135b9476f8c972f8ad063abf598 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:53:50 +0530 Subject: [PATCH 11/91] Say in the producer's contract that an empty candidate list is three facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/adr/0016-master-binding-authority.md | 37 +++++++++++++++++++ .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 12 +++++- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index 61692393..b9aff3a6 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -155,6 +155,43 @@ Candidates are capped at `MAX_CANDIDATES_PER_ENTITY` (25) with the true `candidate_count` and an explicit `candidates_truncated` flag retained, so a truncated list is never mistaken for a short one. +### 4a. An empty candidate list is three different facts, and the producer says which + +`candidates` can be empty for three unrelated reasons, and they mean opposite +things to anyone deciding what to do next: + +| `reason` | what empty means | +| --- | --- | +| `NoCandidate` | no master resembles this name at all | +| `NoDiscriminatingCandidate` | `candidate_count` masters resemble it and none is separable — **many exist**, none is worth showing | +| any, with `candidates_truncated` | the list was cut, by the per-entity cap or by the report's aggregate byte budget | + +So `candidates.is_empty()` alone answers nothing. The disambiguators are +`reason`, `candidate_count` and `candidates_truncated`, and a consumer that +reads the empty vector as "nothing exists" is wrong in two cases out of three. + +This is stated here, in the producer's contract, rather than left to each +consumer to rediscover, 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 had to +add a paired test to stop its own rule collapsing into "no candidates means +unknown" — a reading that is right for the truncated case and wrong for +`NoCandidate`. + +It is the same defect class this ADR was written against: a refusal whose +neighbouring value reads as an answer. The vocabulary is deliberately explicit +so that "nothing survived to be shown" and "nothing exists" cannot be confused +by reading one field. + +**Why this is a doc and not a type.** Making the four cases unrepresentable — +an enum of `Listed` / `Truncated` / `Withheld` / `None` rather than a vector +plus two flags — would be the stronger fix and is the one P2 asks for. It is +deliberately deferred: the change is breaking, a stacked consumer already +depends on `candidates_truncated` as a predicate and holds the boundary with +tests, and forcing that rework while this contract is under review trades a +real improvement for a real regression risk. It should be revisited once this +and its dependent have merged. + ### 5. Status vocabulary Per entity, exactly one of: diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 5ac5de99..aaada3ce 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": "b3c3b808259cdfb7530d489ee389b760708313de9e49c9ed019bf7d22dd0856d", + "compatibility_surface_sha256": "1785bffb059d601272542b55b55ffc9b47fc46798860182fd21704283493597b", "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 fdcfed42..18204a3b 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "adc4318c06e337bff245014f3f585aaad47cc4d6c6a5988c4221b656b51c0b62" + "sha256": "2b45787586520e9bbb33bc2bcbb1384229fde5919e9d3cbca901f44de241080b" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "b3c3b808259cdfb7530d489ee389b760708313de9e49c9ed019bf7d22dd0856d" + "manifest_sha256": "1785bffb059d601272542b55b55ffc9b47fc46798860182fd21704283493597b" } \ No newline at end of file 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 6a478403..8f24848f 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -263,8 +263,18 @@ pub struct Unresolved { /// A parked amount whose identity went into a write-only field is /// unreallocatable, and nothing about the write would say so. pub unresolved_identity: Vec, + /// The masters worth showing, most defensible first. + /// + /// **Empty is three different facts.** With `NoCandidate` it means nothing + /// resembles this name; with `NoDiscriminatingCandidate` it means + /// `candidate_count` masters resemble it and none is separable; with + /// `candidates_truncated` it means the list was cut, by the per-entity cap + /// or the report's aggregate byte budget. Reading the empty vector as + /// "nothing exists" is wrong in two of the three. Disambiguate on `reason` + /// and `candidates_truncated` — see ADR 0016 §4a. pub candidates: Vec, - /// Candidates found before truncation. + /// Masters found before any truncation, including a family that was + /// counted and deliberately not listed. pub candidate_count: usize, pub candidates_truncated: bool, } From a6d95adebed98938ce9ad3ad601fa9612be5be27 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 02:41:20 +0530 Subject: [PATCH 12/91] Follow Tally's own rule for master sameness, and type the candidate listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/adr/0016-master-binding-authority.md | 66 +++++-- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 8 +- .../bridge-tally-core/src/master_binding.rs | 171 +++++++++++++++--- .../src/master_binding_tests.rs | 132 ++++++++++++-- src-tauri/src/agent_import.rs | 21 ++- src-tauri/src/source_draft/catalog.rs | 9 +- 7 files changed, 342 insertions(+), 67 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index b9aff3a6..68cb87df 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -110,6 +110,17 @@ unrelated period-labelled masters fuse on their period. The exclusion can only make a bind less likely, never more, which is the safe direction for a rule whose failure mode is posting against the wrong party. +**Coverage is a property of the client's naming habit, not of the problem.** +Measured across four catalogues: a retail motorcycle dealership carries an +embedded identifier in 91 of 214 ledgers (42%), because it literally names +customers that way; a B2B minerals trader, 0 of 105; a third catalogue, 0 of +470; and Bridge's own synthetic books, 0 of 470 until ten were seeded to give +the rule any live coverage at all. So this rule is a **first-pass check that is +decisive when it fires and absent more often than not** — it resolved a customer +three fuzzy name matches got wrong, and it can never be the primary key. The +binder must work with it absent, and does: name matching is not a fallback here +but the ordinary path. + An identifier binds only when it is **unique on both sides**: exactly one master in the catalog carries it, and the entity's identifiers select exactly one master overall. Any conflict is `Ambiguous`, never a bind. This keeps the @@ -125,9 +136,27 @@ favour. ### 3. Name matching binds only on an exact or normalized-exact unique hit `Exact` is byte equality with the observed master name. `Normalized` is equality -under a comparison key that applies NFC, folds Unicode dash and quote variants -to ASCII, lowercases, and collapses whitespace — and only when exactly one -master shares that key. Nothing else binds. There is no edit distance, no +under **Tally's own rule for when two master names are the same**, and only when +exactly one master shares it. + +That rule is measured, not chosen: `IMPLEMENTATION_GUIDE.md` §3.3b found Tally's +master-name matching to be case-insensitive **and separator-insensitive — a +hyphen matches a space** — and otherwise exact on letters. `AND` for `&`, a +missing suffix word, and a singular for a plural were all rejected. So the fold +lowercases, collapses whitespace, folds Unicode dash and quote variants to +ASCII, and treats `-` as a space; and it stops exactly where Tally stops. + +**Being stricter than the authority is not the safe direction it appears to +be.** It refuses names Tally would accept, and `X - Y` is a common ledger +convention — six of the seventeen hyphenated names in the observed books take +that shape. A binder that reports a near-miss for a name the book would have +matched has invented work, not prevented an error. + +This fold is deliberately **separate from the general comparison key**, which is +shared with other contracts for voucher numbers and voucher-type names. §3.3b +says nothing about those, and widening the shared fold to serve masters would be +the "never to make one caller's case pass" this ADR warns against. One fold per +notion of sameness, each named for the question it answers. Nothing else binds. There is no edit distance, no phonetic key, no token stemming, and no similarity threshold anywhere in the implementation. @@ -183,14 +212,29 @@ neighbouring value reads as an answer. The vocabulary is deliberately explicit so that "nothing survived to be shown" and "nothing exists" cannot be confused by reading one field. -**Why this is a doc and not a type.** Making the four cases unrepresentable — -an enum of `Listed` / `Truncated` / `Withheld` / `None` rather than a vector -plus two flags — would be the stronger fix and is the one P2 asks for. It is -deliberately deferred: the change is breaking, a stacked consumer already -depends on `candidates_truncated` as a predicate and holds the boundary with -tests, and forcing that rework while this contract is under review trades a -real improvement for a real regression risk. It should be revisited once this -and its dependent have merged. +**This is now a type as well as a doc.** `Candidates` is +`None | Listed | Truncated { found } | Withheld { found }`, so a consumer +matching it exhaustively is made to decide each case, and the wrong reading does +not compile rather than failing a test someone remembered to write. `listed()`, +`found()` and `is_incomplete()` cover the callers that do not need to match. +`is_incomplete()` is the predicate that matters: **true means the absence of a +listing is not the absence of a master**, and no consumer may report "nothing +like this is present" over it. + +It was taken before merge deliberately. The contract had not shipped, so this is +the cheapest the change would ever be; afterwards it would be a breaking change +to a published contract with three consumers behind it. The consumer who paid +for it measured its own cost at about thirty lines and reported that the change +made its code better rather than merely compatible — a hand-assembled +disjunction became an exhaustive match. + +**The fix stops at the crate boundary, and says so.** The MCP result carries an +explicit `listing` discriminator, because a model is precisely the caller that +would read an empty array as "no such ledger exists". The desktop DTO stays +flat: its screen already distinguishes the three cases and is tested on each, so +flattening there is a projection with a tested consumer rather than an +ambiguity. Neither boundary has the compiler behind it — this protects Rust +consumers, and the projections are the two places where that protection ends. ### 5. Status vocabulary diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index aaada3ce..62755309 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": "1785bffb059d601272542b55b55ffc9b47fc46798860182fd21704283493597b", + "compatibility_surface_sha256": "0a9ab95b72635f32cb9dee780865f34c08aad7eb8b7b2d98f2ce7a8dea43e953", "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 18204a3b..4496dc48 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "2b45787586520e9bbb33bc2bcbb1384229fde5919e9d3cbca901f44de241080b" + "sha256": "0be68cef1ae12231c218c20ec061eeb141d646698009ef9168ba90d3872bbb2a" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -331,7 +331,7 @@ }, { "path": "src-tauri/src/agent_import.rs", - "sha256": "f531cd0c3bc2a5da6b6dc53271c473bf0b1e1ef776966692a4579651989a67da" + "sha256": "606874ff57fc21b8d94c21b3dfe86f515b476a8b3508615b2d18fea1dc4bb814" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -579,7 +579,7 @@ }, { "path": "src-tauri/src/source_draft/catalog.rs", - "sha256": "2ab349ed9b64ecea7442d604fc8e75c7824daf3cd5945e5c199c68c7097141ed" + "sha256": "d73dd36572f8e27aa8fa34ad7c4a2ae20276df14746f398e83236bde624e16dc" }, { "path": "src-tauri/src/source_draft/files.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "1785bffb059d601272542b55b55ffc9b47fc46798860182fd21704283493597b" + "manifest_sha256": "0a9ab95b72635f32cb9dee780865f34c08aad7eb8b7b2d98f2ce7a8dea43e953" } \ No newline at end of file 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 8f24848f..43bca31f 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -246,6 +246,59 @@ pub enum BindingBasis { NormalizedName, } +/// The masters worth showing, and — in the variant itself — what an absence of +/// them means. +/// +/// Replaces a `Vec` plus two flags, where empty was three different facts and a +/// consumer reading `is_empty()` was wrong in two of them. That shape had +/// already been got wrong twice by different lanes; here the compiler makes +/// each case an explicit decision instead. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case", tag = "listing")] +pub enum Candidates { + /// Nothing resembles this name. An absence of masters, not of information. + None, + /// Every master found, listed. + Listed(Vec), + /// More were found than could be listed — the per-entity cap, or the + /// report's aggregate byte budget. + Truncated { + listed: Vec, + found: usize, + }, + /// A family this name reaches and separates none of: counted, and + /// deliberately not listed, because an arbitrary slice of it put the right + /// master out of view about a third of the time against live books. + Withheld { found: usize }, +} + +impl Candidates { + /// The masters actually listed. Empty for `None` and `Withheld` alike, so + /// never decide anything from this alone. + pub fn listed(&self) -> &[Candidate] { + match self { + Self::None | Self::Withheld { .. } => &[], + Self::Listed(listed) | Self::Truncated { listed, .. } => listed, + } + } + + /// Masters found before any truncation or withholding. + pub fn found(&self) -> usize { + match self { + Self::None => 0, + Self::Listed(listed) => listed.len(), + Self::Truncated { found, .. } | Self::Withheld { found } => *found, + } + } + + /// Whether masters exist that are not in `listed()`. The predicate a + /// consumer needs before it may report "nothing like this is present": + /// true here means the absence of a listing is not the absence of a master. + pub fn is_incomplete(&self) -> bool { + matches!(self, Self::Truncated { .. } | Self::Withheld { .. }) + } +} + /// What could not be bound, and why. This is the operator's work item, not an /// error path. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -263,23 +316,27 @@ pub struct Unresolved { /// A parked amount whose identity went into a write-only field is /// unreallocatable, and nothing about the write would say so. pub unresolved_identity: Vec, - /// The masters worth showing, most defensible first. - /// - /// **Empty is three different facts.** With `NoCandidate` it means nothing - /// resembles this name; with `NoDiscriminatingCandidate` it means - /// `candidate_count` masters resemble it and none is separable; with - /// `candidates_truncated` it means the list was cut, by the per-entity cap - /// or the report's aggregate byte budget. Reading the empty vector as - /// "nothing exists" is wrong in two of the three. Disambiguate on `reason` - /// and `candidates_truncated` — see ADR 0016 §4a. - pub candidates: Vec, - /// Masters found before any truncation, including a family that was - /// counted and deliberately not listed. - pub candidate_count: usize, - pub candidates_truncated: bool, + pub candidates: Candidates, } /// Exactly one outcome per source entity. +/// +/// **What a `Bound` does not establish**, written here because a computed check +/// gets read for more than it covers, and the caller cannot see the gap from +/// the value alone: +/// +/// - **Not that the master still exists.** The catalog is a snapshot. A caller +/// 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. +/// - **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. +/// - **Not any authority.** A binding is a proposal: it approves nothing, +/// creates nothing, and dispatches nothing. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case", tag = "status")] pub enum BindingStatus { @@ -430,6 +487,20 @@ impl BindingReport { /// /// Constructed only from an entity that did not bind, so rebinding something /// that already matched is not a representable state. +/// +/// **Reallocate with a Journal moving the amount off the fallback ledger. +/// Never with `Alter`, and never with `Cancel`.** `TALLY_PROTOCOL_REFERENCE.md` +/// §9.7 measured voucher `Alter` returning `CREATED=1, ALTERED=0` and creating +/// a **duplicate with the target untouched** — four keys tested, all four +/// duplicating — and §9.6 the same for `Cancel`. The counters report success +/// either way, so the obvious correction produces exactly the double-posting a +/// parked entry exists to avoid, and says it worked. +/// +/// Re-import under the same client `REMOTEID` (§3.3a) is a real correction +/// path, but reaches only vouchers Bridge itself wrote; a hand-keyed voucher +/// has no client key. This is why the retained identity travels in the +/// narration: the Journal that reallocates it is written by a human or a later +/// batch, and the narration is what either can still read. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct FallbackBinding { class: MasterClass, @@ -526,7 +597,7 @@ impl SourceEntity { } Ok(Self { position, - key: comparison_key(&name), + key: master_identity_key(&name), name, identifiers, }) @@ -589,7 +660,7 @@ impl MasterCatalog { return Err(MasterBindingError::CatalogDuplicateName); } by_name.insert(name.clone(), entries.len()); - let key = comparison_key(&name); + let key = master_identity_key(&name); entries.push(CatalogEntry { identifiers: extract_identifiers(&name)?, tokens: tokens_of(&key), @@ -822,23 +893,36 @@ fn unresolved_from( .cmp(&right.1.rank()) .then_with(|| left.0.cmp(&right.0)) }); - // A suppressed family is still counted. The operator is told how many - // masters the name reaches even when none of them is worth listing. - let candidate_count = ordered.len().max(masters_found); - let listed = ordered.len().min(MAX_CANDIDATES_PER_ENTITY); - let candidates = ordered - .into_iter() - .take(MAX_CANDIDATES_PER_ENTITY) - .map_while(|(catalog_name, rule)| { - *budget = budget.checked_sub(catalog_name.len())?; - Some(Candidate { catalog_name, rule }) - }) - .collect::>(); + // The variant is derived here, in one place, from the same facts that chose + // the reason — so "empty" can never mean something the variant does not say. + let candidates = if ordered.is_empty() { + if masters_found > 0 { + Candidates::Withheld { + found: masters_found, + } + } else { + Candidates::None + } + } else { + let capped = ordered.len().min(MAX_CANDIDATES_PER_ENTITY); + let listed = ordered + .into_iter() + .take(MAX_CANDIDATES_PER_ENTITY) + .map_while(|(catalog_name, rule)| { + *budget = budget.checked_sub(catalog_name.len())?; + Some(Candidate { catalog_name, rule }) + }) + .collect::>(); + let found = masters_found.max(capped); + if listed.len() < found { + Candidates::Truncated { listed, found } + } else { + Candidates::Listed(listed) + } + }; let unresolved = Unresolved { reason, unresolved_identity: entity.identifiers.clone(), - candidate_count, - candidates_truncated: candidate_count > listed || candidates.len() < listed, candidates, }; if matches!(reason, UnboundReason::NoCandidate) { @@ -998,6 +1082,33 @@ pub(crate) fn comparison_key(value: &str) -> String { .join(" ") } +/// Whether Tally itself would consider two master names the same. +/// +/// This is not `comparison_key`, and the difference is not cosmetic. +/// `IMPLEMENTATION_GUIDE.md` §3.3b measured Tally's own master-name matching: +/// case-insensitive **and separator-insensitive — a hyphen matches a space** — +/// and otherwise exact on letters. `BRIDGE PROBE LEDGER A` matched a live +/// `BRIDGE-PROBE-LEDGER-A`; `AND` for `&`, a missing suffix word and a singular +/// for a plural were all rejected. +/// +/// Tally is the authority on what counts as the same master, so this fold +/// follows it. Being *stricter* than the authority is not the safe direction it +/// looks like: it refuses names Tally would accept, and `X - Y` is a common +/// ledger convention — six of seventeen hyphenated names in the observed books +/// take that shape. +/// +/// It is a **separate** function rather than a widening of `comparison_key` +/// precisely because that one is shared: voucher numbers and voucher-type names +/// fold through it too, and §3.3b says nothing about those. One fold per notion +/// of sameness, each named for the question it answers. +fn master_identity_key(value: &str) -> String { + comparison_key(value) + .replace('-', " ") + .split_whitespace() + .collect::>() + .join(" ") +} + fn tokens_of(key: &str) -> BTreeSet { key.split(|character: char| !character.is_alphanumeric()) .filter(|token| token.chars().count() >= MIN_TOKEN_CHARS) 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 68e6b842..938f4e2b 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 @@ -31,6 +31,7 @@ fn candidate_names(binding: &EntityBinding) -> Vec<&str> { .unresolved() .expect("binding did not resolve") .candidates + .listed() .iter() .map(|candidate| candidate.catalog_name.as_str()) .collect() @@ -255,6 +256,7 @@ fn near_duplicate_masters_produce_candidates_and_choose_none() { assert_eq!( unresolved .candidates + .listed() .iter() .map(|candidate| candidate.rule) .collect::>(), @@ -264,8 +266,8 @@ fn near_duplicate_masters_produce_candidates_and_choose_none() { CandidateRule::SharedToken ] ); - assert_eq!(unresolved.candidate_count, 3); - assert!(!unresolved.candidates_truncated); + assert_eq!(unresolved.candidates.found(), 3); + assert!(!unresolved.candidates.is_incomplete()); } #[test] @@ -288,6 +290,7 @@ fn a_truncated_source_name_surfaces_the_longer_master() { .unresolved() .expect("unbound") .candidates + .listed() .first() .map(|candidate| candidate.rule), Some(CandidateRule::CatalogPrefix) @@ -301,6 +304,7 @@ fn a_source_name_extending_a_master_surfaces_the_shorter_master() { let unresolved = binding.unresolved().expect("unbound"); assert!(unresolved .candidates + .listed() .iter() .any(|candidate| candidate.rule == CandidateRule::SourcePrefix && candidate.catalog_name == "DELTA WHOLESALE")); @@ -410,6 +414,47 @@ fn a_decisive_identifier_pointing_elsewhere_still_outranks_a_byte_exact_name() { ); } +#[test] +fn a_hyphen_matches_a_space_because_tally_says_so() { + // IMPLEMENTATION_GUIDE.md §3.3b, measured: Tally's own master-name matching + // treats a hyphen as a space. Being stricter than the authority refuses + // names Tally would accept, and `X - Y` is a common ledger convention. + let catalog = ledgers(&["Bank - HDFC Current", "Beta Supply"]); + let binding = bind_one_name(&catalog, "Bank HDFC Current"); + assert_eq!( + binding.status, + BindingStatus::Bound { + catalog_name: "Bank - HDFC Current".to_string(), + basis: BindingBasis::NormalizedName, + } + ); + // And the reverse direction. + let hyphenated = ledgers(&["BRIDGE PROBE LEDGER A", "Beta Supply"]); + assert_eq!( + bind_one_name(&hyphenated, "BRIDGE-PROBE-LEDGER-A").bound_name(), + Some("BRIDGE PROBE LEDGER A") + ); +} + +#[test] +fn the_master_fold_stops_where_tally_stops() { + // §3.3b also measured what Tally does NOT normalise: `AND` for `&`, a + // missing suffix word, and a singular for a plural were all rejected. + // Folding further than the authority would bind names Tally refuses. + let catalog = ledgers(&["ZZ Ram & Sons Pvt Ltd", "Beta Supply"]); + for wrong in [ + "ZZ Ram AND Sons Pvt Ltd", + "ZZ Ram & Sons", + "ZZ Ram & Son Pvt Ltd", + ] { + assert_eq!( + bind_one_name(&catalog, wrong).bound_name(), + None, + "{wrong:?} bound, but Tally rejects it" + ); + } +} + #[test] fn a_trailing_space_never_claims_byte_equality() { // `Bank ` against live `Bank` must not report exact: the import file would @@ -497,6 +542,7 @@ fn a_report_bounds_its_own_candidate_allocation() { .map(|unresolved| { unresolved .candidates + .listed() .iter() .map(|candidate| candidate.catalog_name.len()) .sum::() @@ -509,12 +555,12 @@ fn a_report_bounds_its_own_candidate_allocation() { let starved = report .unbound() .filter_map(|entity| entity.unresolved()) - .filter(|unresolved| unresolved.candidates.is_empty()) + .filter(|unresolved| unresolved.candidates.listed().is_empty()) .collect::>(); assert!(!starved.is_empty(), "the budget must actually bite here"); - assert!(starved - .iter() - .all(|unresolved| unresolved.candidate_count > 0 && unresolved.candidates_truncated)); + assert!(starved.iter().all( + |unresolved| unresolved.candidates.found() > 0 && unresolved.candidates.is_incomplete() + )); } #[test] @@ -547,6 +593,59 @@ fn more_identifiers_than_the_bound_is_refused_not_truncated() { ); } +#[test] +fn the_listing_variant_says_what_an_absent_candidate_means() { + // The three facts that used to share one empty vector, now told apart by + // the type. A consumer matching exhaustively is made to decide each. + let catalog = ledgers(&["Alpha Traders", "Beta Supply"]); + assert_eq!( + bind_one_name(&catalog, "Zeta Placeholder") + .unresolved() + .expect("unbound") + .candidates, + Candidates::None, + "nothing resembles it" + ); + + let family = (0..MAX_PREFIX_FAMILY + 5) + .map(|index| format!("ALPHAGROUP UNIT {index:02}")) + .collect::>(); + let family = MasterCatalog::new(MasterClass::Ledger, &family).expect("valid"); + assert_eq!( + bind_one_name(&family, "ALPHAGROUP") + .unresolved() + .expect("unbound") + .candidates, + Candidates::Withheld { + found: MAX_PREFIX_FAMILY + 5 + }, + "many exist and none separates them" + ); + + let listed = ledgers(&["ALPHA SALE", "ALPHA SALES", "SALES - ALPHA", "Beta Supply"]); + let binding = bind_one_name(&listed, "ALPHA"); + let candidates = &binding.unresolved().expect("unbound").candidates; + assert!(matches!(candidates, Candidates::Listed(_))); + assert_eq!(candidates.found(), 3); +} + +#[test] +fn only_an_incomplete_listing_may_withhold_an_absence() { + // The predicate a consumer needs before reporting "nothing like this is + // present". `None` permits that conclusion; the other two forbid it. + assert!(!Candidates::None.is_incomplete()); + assert!(!Candidates::Listed(Vec::new()).is_incomplete()); + assert!(Candidates::Withheld { found: 30 }.is_incomplete()); + assert!(Candidates::Truncated { + listed: Vec::new(), + found: 9 + } + .is_incomplete()); + // `found` is the total, never the listed length, wherever it is known. + assert_eq!(Candidates::Withheld { found: 30 }.found(), 30); + assert!(Candidates::Withheld { found: 30 }.listed().is_empty()); +} + // --- candidate discipline -------------------------------------------------- #[test] @@ -558,7 +657,7 @@ fn a_catalog_wide_token_stops_discriminating() { let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); // "placeholder" is carried by every entry, so it may not pull all 41 in. let binding = bind_one_name(&catalog, "PLACEHOLDER ZETA"); - assert!(binding.unresolved().expect("unbound").candidate_count <= 1); + assert!(binding.unresolved().expect("unbound").candidates.found() <= 1); } #[test] @@ -575,9 +674,9 @@ fn a_prefix_matching_a_whole_family_is_counted_and_deliberately_not_listed() { let binding = bind_one_name(&catalog, "ALPHAGROUP"); let unresolved = binding.unresolved().expect("unbound"); assert_eq!(reason(&binding), UnboundReason::NoDiscriminatingCandidate); - assert!(unresolved.candidates.is_empty()); - assert_eq!(unresolved.candidate_count, MAX_PREFIX_FAMILY + 5); - assert!(unresolved.candidates_truncated); + assert!(unresolved.candidates.listed().is_empty()); + assert_eq!(unresolved.candidates.found(), MAX_PREFIX_FAMILY + 5); + assert!(unresolved.candidates.is_incomplete()); } #[test] @@ -589,8 +688,8 @@ fn a_family_within_the_bound_is_still_listed_in_full() { let binding = bind_one_name(&catalog, "ALPHAGROUP"); let unresolved = binding.unresolved().expect("unbound"); assert_eq!(reason(&binding), UnboundReason::NearMiss); - assert_eq!(unresolved.candidates.len(), MAX_PREFIX_FAMILY); - assert!(!unresolved.candidates_truncated); + assert_eq!(unresolved.candidates.listed().len(), MAX_PREFIX_FAMILY); + assert!(!unresolved.candidates.is_incomplete()); } #[test] @@ -609,8 +708,8 @@ fn the_reported_count_is_the_union_of_suppressed_and_listed_candidates() { let unresolved = binding.unresolved().expect("unbound"); // The shorter master is still listed; the family behind it is not. assert_eq!(candidate_names(&binding), ["Alpha"]); - assert_eq!(unresolved.candidate_count, MAX_PREFIX_FAMILY + 6); - assert!(unresolved.candidates_truncated); + assert_eq!(unresolved.candidates.found(), MAX_PREFIX_FAMILY + 6); + assert!(unresolved.candidates.is_incomplete()); } #[test] @@ -637,6 +736,7 @@ fn candidate_order_is_rule_then_name_and_never_a_ranking() { assert_eq!( unresolved .candidates + .listed() .iter() .map(|candidate| (candidate.catalog_name.as_str(), candidate.rule)) .collect::>(), @@ -1005,9 +1105,9 @@ fn a_firm_wide_word_does_not_drag_the_whole_book_into_every_candidate_list() { let binding = bind_one_name(&catalog, "OMEGA PLACEHOLDER"); let unresolved = binding.unresolved().expect("unbound"); assert!( - unresolved.candidate_count <= MAX_CANDIDATES_PER_ENTITY, + unresolved.candidates.found() <= MAX_CANDIDATES_PER_ENTITY, "a firm-wide word pulled in {} candidates", - unresolved.candidate_count + unresolved.candidates.found() ); } diff --git a/src-tauri/src/agent_import.rs b/src-tauri/src/agent_import.rs index e560b5c2..2589d3b4 100644 --- a/src-tauri/src/agent_import.rs +++ b/src-tauri/src/agent_import.rs @@ -9,7 +9,8 @@ use crate::tally::standard_ledger_catalog::{ render_standard_ledger_catalog_request, }; use bridge_tally_core::master_binding::{ - self, BindingBasis, BindingStatus, EntityBinding, MasterCatalog, MasterClass, SourceEntity, + self, BindingBasis, BindingStatus, Candidates, EntityBinding, MasterCatalog, MasterClass, + SourceEntity, }; use bridge_tally_core::ExactDecimal; use bridge_tally_protocol::outstandings_shared::DateBoundaryProfile; @@ -1188,6 +1189,7 @@ fn master_match_json(binding: &EntityBinding) -> Value { let mut bytes = 0_usize; let candidates = unresolved .candidates + .listed() .iter() .take_while(|candidate| { bytes = bytes.saturating_add(candidate.catalog_name.len()); @@ -1200,6 +1202,18 @@ fn master_match_json(binding: &EntityBinding) -> Value { }) }) .collect::>(); + // The listing state is carried explicitly rather than left to be + // inferred from an empty array. A model is exactly the caller that + // would read "no candidates" as "no such ledger exists", and for + // `withheld` that is false: masters were found and deliberately not + // listed because none of them separates the requested name. + let found = unresolved.candidates.found(); + let listing = match unresolved.candidates { + Candidates::None => "none", + Candidates::Withheld { .. } => "withheld", + _ if candidates.len() < found => "truncated", + _ => "listed", + }; // No `exact_live_spelling`. Naming one candidate as the live // spelling is the auto-resolution that rejected a batch once. json!({ @@ -1209,8 +1223,9 @@ fn master_match_json(binding: &EntityBinding) -> Value { _ => "near_miss", }, "reason": unresolved.reason.safe_reason_code(), - "candidate_count": unresolved.candidate_count, - "candidates_truncated": candidates.len() < unresolved.candidate_count, + "listing": listing, + "candidate_count": found, + "candidates_truncated": listing != "listed" && listing != "none", "candidates": candidates, "unresolved_identity": unresolved .unresolved_identity diff --git a/src-tauri/src/source_draft/catalog.rs b/src-tauri/src/source_draft/catalog.rs index dd3cae80..de5979ec 100644 --- a/src-tauri/src/source_draft/catalog.rs +++ b/src-tauri/src/source_draft/catalog.rs @@ -189,13 +189,18 @@ fn source_entry_bindings( bound_target: None, bound_basis: None, unbound_reason: Some(unresolved.reason.safe_reason_code()), - candidates_truncated: unresolved.candidates_truncated, + // The screen distinguishes the three cases from + // `candidate_count` against an empty list and is tested + // on each, so the DTO stays flat and this projection is + // the only place the typed shape is flattened. + candidates_truncated: unresolved.candidates.is_incomplete(), candidates: unresolved .candidates + .listed() .iter() .map(|candidate| candidate.catalog_name.clone()) .collect(), - candidate_count: unresolved.candidate_count, + candidate_count: unresolved.candidates.found(), } } }, From 168e094e147972e7442b4c7c0f54b653c387c099 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 02:57:18 +0530 Subject: [PATCH 13/91] Close the six findings the contract change generated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/adr/0016-master-binding-authority.md | 31 +++- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 174 ++++++++++++------ .../src/master_binding_tests.rs | 97 +++++++++- 5 files changed, 246 insertions(+), 62 deletions(-) diff --git a/docs/adr/0016-master-binding-authority.md b/docs/adr/0016-master-binding-authority.md index 68cb87df..f8e553e4 100644 --- a/docs/adr/0016-master-binding-authority.md +++ b/docs/adr/0016-master-binding-authority.md @@ -98,11 +98,32 @@ reference, say): a rate, a house number and a masked last-four cannot qualify — a last-four written as digits falls through to near-miss rather than binding two accounts that share four digits. -- **Code** — a token holding at least one letter and at least - `MIN_CODE_IDENTIFIER_DIGITS` (2) digits, of at least - `MIN_CODE_IDENTIFIER_CHARS` (4) alphanumeric characters. Canonical form is - uppercase alphanumerics, so a punctuated part number and an unpunctuated one - agree. +- **Code** — a token holding at least two letters and at least + `MIN_CODE_IDENTIFIER_DIGITS` (3) digits, of at least + `MIN_CODE_IDENTIFIER_CHARS` (8) alphanumeric characters, and not a period + label. Canonical form is uppercase alphanumerics, so a punctuated part number + and an unpunctuated one agree. + + These thresholds were raised twice under review, from 4/2/1. Enumerating the + period spellings that must not become identifiers — `FY25`, then `APR2025`, + then `SEPTEMBER2025` — kept losing to the next spelling, so length carries + what a list of prefixes could not: a registration code clears eight + alphanumerics with three digits, and a period label does not. **Measured + against 485 live ledger names, exactly one yields a code identifier at all**, + so the cost of the strictness is nothing observed. + +**A token carrying letters never yields a standalone numeric**, whether or not +it qualified as a code. Otherwise `Part A12345678` reaches an unrelated +`Bank 12345678` through the one-letter gap the code test rejects: a token +identifies by its whole shape or not at all. + +**Period labels are recognized by their numbers, not their words.** A token is +a period when every number in it reads as a year or a small ordinal — which +catches `SEPTEMBER2025` and `2025QUARTER1` that no cap on the alphabetic run +ever would, because a month name can be any length and a year cannot. A fiscal +range (`2025-2026`, `2025/2026`) is excluded before its digits are fused, since +stripping the separator produced an eight-digit run that no calendar reading +rejects. One narrow exclusion applies to the numeric shape: an eight-digit run that reads as a calendar date in 1900–2199 is a date, not an identifier. Without it two diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 62755309..33b79ada 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": "0a9ab95b72635f32cb9dee780865f34c08aad7eb8b7b2d98f2ce7a8dea43e953", + "compatibility_surface_sha256": "88ff6fd9b10c884a2b19c4241f0912e06e1f516c291e9ee5e30e89aec9e756a4", "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 4496dc48..77d7ec88 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "0be68cef1ae12231c218c20ec061eeb141d646698009ef9168ba90d3872bbb2a" + "sha256": "5a61b51c2ef36b63693a3114a5779acfb5ce8dbd9e6e504a2a9b15ad31aac5b8" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "0a9ab95b72635f32cb9dee780865f34c08aad7eb8b7b2d98f2ce7a8dea43e953" + "manifest_sha256": "88ff6fd9b10c884a2b19c4241f0912e06e1f516c291e9ee5e30e89aec9e756a4" } \ No newline at end of file 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 43bca31f..f923096f 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -117,7 +117,8 @@ pub enum MasterBindingError { TooManyIdentifiers, #[error("fallback master was not a current catalog entry")] FallbackNotInCatalog, - /// A catalog of the wrong class, or an entity from another report. + /// A catalog of the wrong class, a catalog the report was not produced + /// from, or an entity from another report. #[error("catalog did not match the report it is used with")] ClassMismatch, } @@ -393,6 +394,7 @@ pub struct BindingTotals { #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct BindingReport { class: MasterClass, + catalog: CatalogFingerprint, entities: Vec, } @@ -401,6 +403,11 @@ impl BindingReport { self.class } + /// The catalog this report was produced from. + pub fn catalog(&self) -> CatalogFingerprint { + self.catalog + } + pub fn entities(&self) -> &[EntityBinding] { &self.entities } @@ -434,7 +441,10 @@ impl BindingReport { catalog: &MasterCatalog, fallback_name: &str, ) -> Result { - if catalog.class != self.class { + // Class alone is not provenance: two ledger catalogs are both + // `Ledger`, and a fallback drawn from the one the report never saw + // would name a master that was never a candidate for this entity. + if catalog.class != self.class || catalog.fingerprint != self.catalog { return Err(MasterBindingError::ClassMismatch); } let entity = self @@ -628,9 +638,18 @@ struct CatalogEntry { /// /// Valid by construction: `bind` cannot fail because everything that could fail /// was decided here. +/// Which catalog a report was produced from. +/// +/// Not a security property and not a Tally identity — it distinguishes two +/// catalogs of the same class held in one process, which is the state that let +/// a fallback be drawn from a book the report never saw. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub struct CatalogFingerprint(u64); + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MasterCatalog { class: MasterClass, + fingerprint: CatalogFingerprint, entries: Vec, by_name: BTreeMap, by_key: BTreeMap>, @@ -703,8 +722,21 @@ impl MasterCatalog { BTreeSet::new() }; + // Order-independent, so the same masters read twice fingerprint alike + // however the book returned them. + let fingerprint = + CatalogFingerprint(entries.iter().fold(class as u64 + 1, |accumulated, entry| { + accumulated + ^ entry + .name + .bytes() + .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x1000_0000_01b3) + }) + })); Ok(Self { class, + fingerprint, entries, by_name, by_key, @@ -718,6 +750,12 @@ impl MasterCatalog { self.class } + /// Which catalog this is, for a caller that must prove a later value came + /// from the same one. + pub fn fingerprint(&self) -> CatalogFingerprint { + self.fingerprint + } + /// Masters in this catalog. Never zero: an empty catalog is refused at /// construction, so there is no emptiness for a caller to test. pub fn master_count(&self) -> usize { @@ -752,6 +790,7 @@ pub fn bind( let mut budget = MAX_REPORT_CANDIDATE_BYTES; Ok(BindingReport { class: catalog.class, + catalog: catalog.fingerprint, entities: entities .iter() .map(|entity| bind_one(catalog, entity, &mut budget)) @@ -849,7 +888,7 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity, budget: &mut usize) } else { UnboundReason::NoCandidate }; - unresolved_from(entity, reason, candidates, masters_found, budget) + unresolved_from(catalog, entity, reason, candidates, masters_found, budget) } } }; @@ -871,27 +910,28 @@ fn unresolved_status( ) -> BindingStatus { let (mut candidates, masters_found) = collect_candidates(catalog, entity, identifier_matches); if let Some(index) = exact { - let name = catalog.entries[index].name.as_str(); - if !candidates.iter().any(|(candidate, _)| candidate == name) { - candidates.push((name.to_string(), CandidateRule::NormalizedEqual)); + if !candidates.iter().any(|(candidate, _)| *candidate == index) { + candidates.push((index, CandidateRule::NormalizedEqual)); } } - unresolved_from(entity, reason, candidates, masters_found, budget) + unresolved_from(catalog, entity, reason, candidates, masters_found, budget) } fn unresolved_from( + catalog: &MasterCatalog, entity: &SourceEntity, reason: UnboundReason, - candidates: Vec<(String, CandidateRule)>, + candidates: Vec<(usize, CandidateRule)>, masters_found: usize, budget: &mut usize, ) -> BindingStatus { let mut ordered = candidates; ordered.sort_by(|left, right| { - left.1 - .rank() - .cmp(&right.1.rank()) - .then_with(|| left.0.cmp(&right.0)) + left.1.rank().cmp(&right.1.rank()).then_with(|| { + catalog.entries[left.0] + .name + .cmp(&catalog.entries[right.0].name) + }) }); // The variant is derived here, in one place, from the same facts that chose // the reason — so "empty" can never mean something the variant does not say. @@ -908,9 +948,13 @@ fn unresolved_from( let listed = ordered .into_iter() .take(MAX_CANDIDATES_PER_ENTITY) - .map_while(|(catalog_name, rule)| { + .map_while(|(index, rule)| { + let catalog_name = &catalog.entries[index].name; *budget = budget.checked_sub(catalog_name.len())?; - Some(Candidate { catalog_name, rule }) + Some(Candidate { + catalog_name: catalog_name.clone(), + rule, + }) }) .collect::>(); let found = masters_found.max(capped); @@ -939,7 +983,7 @@ fn collect_candidates( catalog: &MasterCatalog, entity: &SourceEntity, identifier_matches: &BTreeSet, -) -> (Vec<(String, CandidateRule)>, usize) { +) -> (Vec<(usize, CandidateRule)>, usize) { let mut best: BTreeMap = BTreeMap::new(); let mut offer = |index: usize, rule: CandidateRule| { best.entry(index) @@ -1015,12 +1059,10 @@ fn collect_candidates( .chain(suppressed_family) .collect::>() .len(); - ( - best.into_iter() - .map(|(index, rule)| (catalog.entries[index].name.clone(), rule)) - .collect(), - found, - ) + // Indices, not names. Cloning every match before the cap and the budget + // discarded most of the work for an entity whose identifier is shared by + // many rows, and an admitted draft repeats that per entry. + (best.into_iter().collect(), found) } /// A name is retained **verbatim**, on both sides. @@ -1147,14 +1189,23 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro kind: IdentifierKind::Code, value: canonical, }); - // Its digits are part of this code, not an identifier of their own. + } + // Digits sitting beside letters belong to that token, whether or not it + // qualified as a code. Emitting them separately let `Part A12345678` + // reach an unrelated `Bank 12345678` through the one-letter gap that + // the code test rejects — a token either identifies by its whole shape + // or not at all. + if letters > 0 { continue; } for run in token.split(|character: char| { !(character.is_ascii_digit() || character == '-' || character == '/') }) { let digits = run.chars().filter(char::is_ascii_digit).collect::(); - if digits.len() >= MIN_NUMERIC_IDENTIFIER_DIGITS && !is_plausible_date(&digits) { + if digits.len() >= MIN_NUMERIC_IDENTIFIER_DIGITS + && !is_plausible_date(&digits) + && !is_year_range(run) + { identifiers.insert(Identifier { kind: IdentifierKind::Numeric, value: digits, @@ -1170,25 +1221,24 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro /// A period label identifies a period, not a party or an item. Two unrelated /// ledgers routinely share one — `Purchases FY2025` and `Sales FY2025`, -/// `Purchases APR2025` and `Sales APR2025` — and identifier-first matching -/// would bind the source to whichever exists before it compared the names. +/// `Purchases SEPTEMBER2025` and `Sales SEPTEMBER2025` — and identifier-first +/// matching would bind the source to whichever exists before comparing names. /// -/// Recognized by *shape* rather than by a vocabulary of prefixes, because a -/// list of prefixes kept missing one more spelling: every run in the token is -/// either a short alphabetic marker or a number that reads as a year or a -/// small ordinal, and there are at most three runs. `FY2025`, `APR2025`, -/// `2025Q1` and `Q3` all match; `PH01AB00` and `AB12345678` do not. +/// The test is on the **numbers**, not on the words: a token is a period label +/// when it carries at least one number and **every** number in it reads as a +/// year or a small ordinal. Capping the length of the alphabetic run was the +/// previous attempt and it kept losing to longer spellings — `APR2025` was +/// caught while `SEPTEMBER2025` and `2025QUARTER1` walked through. A month name +/// can be any length; a year cannot. /// -/// Like every exclusion here it can only make a bind *less* likely. +/// An identity-bearing code survives this because its digits do not read as +/// periods: `PH01AB00` carries `00`, `AB12345678` carries an eight-digit run, +/// and a registration number carries something no calendar would produce. Like +/// every exclusion here it can only make a bind *less* likely. fn is_period_label(canonical: &str) -> bool { - let mut runs = 0_usize; - let mut has_period_number = false; + let mut has_number = false; let mut rest = canonical; while !rest.is_empty() { - runs += 1; - if runs > 3 { - return false; - } let alphabetic = rest.starts_with(|character: char| character.is_ascii_alphabetic()); let split = rest .find(|character: char| character.is_ascii_alphabetic() != alphabetic) @@ -1196,26 +1246,44 @@ fn is_period_label(canonical: &str) -> bool { let (run, tail) = rest.split_at(split); rest = tail; if alphabetic { - if run.len() > 4 { - return false; - } - } else { - let value = run.parse::().unwrap_or(u32::MAX); - let reads_as_period = match run.len() { - 1 | 2 => (1..=99).contains(&value), - 4 => (1900..=2199).contains(&value), - _ => false, - }; - if !reads_as_period { - return false; - } - has_period_number = true; + continue; + } + if !reads_as_period_number(run) { + return false; } + has_number = true; + } + has_number +} + +/// A year, or a small ordinal such as a month or quarter. +fn reads_as_period_number(run: &str) -> bool { + let value = run.parse::().unwrap_or(u32::MAX); + match run.len() { + 1 | 2 => (1..=99).contains(&value), + 4 => (1900..=2199).contains(&value), + _ => false, + } +} + +/// `2025-2026` and `2025/2026` are fiscal years, which two unrelated ledgers +/// share as routinely as they share a month. Stripping the separator turned +/// them into an eight-digit run that no calendar-date reading rejects, so the +/// range has to be recognized before the digits are fused. +fn is_year_range(run: &str) -> bool { + let mut halves = run.split(['-', '/']); + match (halves.next(), halves.next(), halves.next()) { + (Some(first), Some(second), None) => [first, second].iter().all(|half| { + half.len() == 4 + && half + .parse::() + .is_ok_and(|year| (1900..=2199).contains(&year)) + }), + _ => false, } - has_period_number } -/// An eight-digit run that reads as a calendar date in any order this project/// An eight-digit run that reads as a calendar date in any order this project +/// An eight-digit run that reads as a calendar date in any order this project/// An eight-digit run that reads as a calendar date in any order this project/// An eight-digit run that reads as a calendar date in any order this project /// admits is a date, not an identifier. Recognizing only `YYYYMMDD` left /// `01012026` binding a source to an unrelated master that shares its period /// label. Being generous here can only make a bind *less* likely, which is the 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 938f4e2b..7151ad1f 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 @@ -501,7 +501,21 @@ fn a_fiscal_period_label_is_not_an_identity_bearing_code() { // matching would otherwise bind the source to whichever one exists before // it ever compared the names. for label in [ - "FY25", "FY2025", "AY2026", "Q3", "H2", "PER2026", "APR2025", "2025Q1", "MAR26", "H12026", + "FY25", + "FY2025", + "AY2026", + "Q3", + "H2", + "PER2026", + "APR2025", + "2025Q1", + "MAR26", + "H12026", + // A month name can be any length; a year cannot. Capping the + // alphabetic run kept losing to longer spellings. + "SEPTEMBER2025", + "2025QUARTER1", + "DECEMBER2026", ] { assert!( entity(&format!("Purchases {label}")) @@ -563,6 +577,44 @@ fn a_report_bounds_its_own_candidate_allocation() { )); } +#[test] +fn a_token_carrying_letters_never_yields_a_standalone_number() { + // A one-letter token fails the code test, and its digits were then escaping + // as a numeric of their own — so `Part A12345678` could reach an unrelated + // `Bank 12345678`. A token identifies by its whole shape or not at all. + assert!(entity("Part A12345678").identifiers().is_empty()); + let catalog = ledgers(&["Bank 12345678", "Beta Supply"]); + assert_eq!(bind_one_name(&catalog, "Part A12345678").bound_name(), None); + // A bare digit run beside no letters is still an identifier. + assert_eq!(entity("Party (5550001001)").identifiers().len(), 1); +} + +#[test] +fn a_fiscal_year_range_is_a_period_not_an_account_number() { + // `2025-2026` strips to an eight-digit run that no calendar reading + // rejects, and two unrelated ledgers share a fiscal year as routinely as + // they share a month. + for range in ["2025-2026", "2025/2026", "1999-2000"] { + assert!( + entity(&format!("Purchases {range}")) + .identifiers() + .is_empty(), + "{range} was treated as an identifier" + ); + } + let catalog = ledgers(&["Sales 2025-2026", "Beta Supply"]); + assert_eq!( + bind_one_name(&catalog, "Purchases 2025-2026").bound_name(), + None + ); + // A punctuated account number that is not a year range still binds. + let accounts = ledgers(&["Party 5550001-002", "Beta Supply"]); + assert_eq!( + bind_one_name(&accounts, "Other 5550001002").bound_name(), + Some("Party 5550001-002") + ); +} + #[test] fn an_eight_digit_date_in_any_admitted_order_is_not_an_identifier() { for date in ["20260910", "01012026", "31122026", "12312026"] { @@ -854,12 +906,55 @@ fn a_fallback_cannot_be_drawn_from_another_catalog_class_or_another_report() { stock_report.assign_fallback(7, &stock, "Scrap Placeholder"), Err(MasterBindingError::ClassMismatch) ); + // Nor may a *same-class* catalog the report was never produced from supply + // the fallback: class is not provenance, and the master would never have + // been a candidate for this entity. + let other_ledgers = ledgers(&["Alpha Traders", "Different Suspense"]); + let ledger_report = bound(&ledger, &[entity("Zeta Placeholder")]); + assert_eq!( + ledger_report.assign_fallback(0, &other_ledgers, "Different Suspense"), + Err(MasterBindingError::ClassMismatch) + ); + assert_ne!(ledger.fingerprint(), other_ledgers.fingerprint()); + // The same masters read twice fingerprint alike, whatever order they came + // back in — a re-read must not invalidate a report. + let reordered = ledgers(&["Suspense Placeholder", "Alpha Traders", "Beta Supply"]); + let forward = ledgers(&["Alpha Traders", "Beta Supply", "Suspense Placeholder"]); + assert_eq!(reordered.fingerprint(), forward.fingerprint()); assert_eq!( MasterBindingError::ClassMismatch.safe_reason_code(), "master_class_mismatch" ); } +#[test] +fn the_adr_quotes_the_thresholds_this_module_actually_uses() { + // ADR 0016 is the contract two surfaces integrate against, so a threshold + // that moves in code and not in the document sends a future integration + // the wrong rule. "Remember to update the record" is the kind of rule this + // project prefers to replace with something that fails. + const ADR: &str = include_str!("../../../../docs/adr/0016-master-binding-authority.md"); + for (constant, value) in [ + ( + "MIN_NUMERIC_IDENTIFIER_DIGITS", + MIN_NUMERIC_IDENTIFIER_DIGITS, + ), + ("MIN_CODE_IDENTIFIER_DIGITS", MIN_CODE_IDENTIFIER_DIGITS), + ("MIN_CODE_IDENTIFIER_CHARS", MIN_CODE_IDENTIFIER_CHARS), + ("MAX_CANDIDATES_PER_ENTITY", MAX_CANDIDATES_PER_ENTITY), + ("COMMON_TOKEN_PERCENT", COMMON_TOKEN_PERCENT), + ] { + // A percentage reads naturally as `(10%)`; both spellings count, and + // neither lets a changed number pass. + let plain = format!("`{constant}` ({value})"); + let percent = format!("`{constant}` ({value}%)"); + assert!( + ADR.contains(&plain) || ADR.contains(&percent), + "ADR 0016 does not quote {constant} as {value}; it must read {plain:?}" + ); + } +} + // --- the vocabulary is stable ---------------------------------------------- #[test] From c7cccbcb3a92415d05e84852b87e3a07984794cc Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 03:07:07 +0530 Subject: [PATCH 14/91] Stop a weaker rule reinstating a withheld family, and make Listed serializable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 118 +++++++++++------- .../src/master_binding_tests.rs | 62 ++++++++- 4 files changed, 134 insertions(+), 52 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 33b79ada..5bee5cef 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": "88ff6fd9b10c884a2b19c4241f0912e06e1f516c291e9ee5e30e89aec9e756a4", + "compatibility_surface_sha256": "36b93b23265867e8b69a3ff288721e1a783820d88ef49d146a7e2fd26f7b8969", "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 77d7ec88..5a94d598 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "5a61b51c2ef36b63693a3114a5779acfb5ce8dbd9e6e504a2a9b15ad31aac5b8" + "sha256": "d0ef94689fd4f0d91649beb105e68aa46a64e1c6e73f116e3e8a7ea01bf18bc3" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "88ff6fd9b10c884a2b19c4241f0912e06e1f516c291e9ee5e30e89aec9e756a4" + "manifest_sha256": "36b93b23265867e8b69a3ff288721e1a783820d88ef49d146a7e2fd26f7b8969" } \ No newline at end of file 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 f923096f..fe6d3c55 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -260,7 +260,12 @@ pub enum Candidates { /// Nothing resembles this name. An absence of masters, not of information. None, /// Every master found, listed. - Listed(Vec), + /// + /// A struct variant, not a newtype: under Serde's internally tagged + /// representation a tag cannot be merged into a sequence, and a newtype + /// here failed to serialize at runtime — on the most common unresolved + /// result, while the other three variants succeeded. + Listed { listed: Vec }, /// More were found than could be listed — the per-entity cap, or the /// report's aggregate byte budget. Truncated { @@ -279,7 +284,7 @@ impl Candidates { pub fn listed(&self) -> &[Candidate] { match self { Self::None | Self::Withheld { .. } => &[], - Self::Listed(listed) | Self::Truncated { listed, .. } => listed, + Self::Listed { listed } | Self::Truncated { listed, .. } => listed, } } @@ -287,7 +292,7 @@ impl Candidates { pub fn found(&self) -> usize { match self { Self::None => 0, - Self::Listed(listed) => listed.len(), + Self::Listed { listed } => listed.len(), Self::Truncated { found, .. } | Self::Withheld { found } => *found, } } @@ -961,7 +966,7 @@ fn unresolved_from( if listed.len() < found { Candidates::Truncated { listed, found } } else { - Candidates::Listed(listed) + Candidates::Listed { listed } } }; let unresolved = Unresolved { @@ -984,6 +989,28 @@ fn collect_candidates( entity: &SourceEntity, identifier_matches: &BTreeSet, ) -> (Vec<(usize, CandidateRule)>, usize) { + // Masters this name reaches by prefix. The key index is ordered, so this is + // a range walk rather than a scan of the catalog per entity. + let extending = if entity.key.chars().count() >= MIN_PREFIX_KEY_CHARS { + catalog + .by_key + .range(entity.key.clone()..) + .take_while(|(key, _)| key.starts_with(&entity.key)) + .filter(|(key, _)| *key != &entity.key) + .flat_map(|(_, holders)| holders.iter().copied()) + .collect::>() + } else { + BTreeSet::new() + }; + // Beyond the bound they are a family this name does not separate, and an + // arbitrary capped slice of one omitted the right master about a third of + // the time against live books. Counted, and withheld rather than listed. + let withheld = if extending.len() > MAX_PREFIX_FAMILY { + extending.clone() + } else { + BTreeSet::new() + }; + let mut best: BTreeMap = BTreeMap::new(); let mut offer = |index: usize, rule: CandidateRule| { best.entry(index) @@ -995,46 +1022,41 @@ fn collect_candidates( .or_insert(rule); }; + // A decisive rule reaches a master on its own evidence, so it still applies + // to a member of a withheld family: the identifier, or the whole key, is + // exactly what separates that one from its siblings. for index in identifier_matches { offer(*index, CandidateRule::SharedIdentifier); } - if let Some(holders) = catalog.by_key.get(&entity.key) { - for index in holders { - offer(*index, CandidateRule::NormalizedEqual); - } + for index in catalog.by_key.get(&entity.key).into_iter().flatten() { + offer(*index, CandidateRule::NormalizedEqual); } - let mut suppressed_family: BTreeSet = BTreeSet::new(); - if entity.key.chars().count() >= MIN_PREFIX_KEY_CHARS { - // The key index is ordered, so both prefix directions are range or - // point lookups rather than a scan of the whole catalog per entity. - let extending = catalog - .by_key - .range(entity.key.clone()..) - .take_while(|(key, _)| key.starts_with(&entity.key)) - .filter(|(key, _)| *key != &entity.key) - .flat_map(|(_, holders)| holders.iter().copied()) - .collect::>(); - // A prefix matching a whole family distinguishes nothing inside it, and - // an arbitrary capped slice is worse than none: measured against live - // books, that slice omitted the right master about a third of the time. - if extending.len() <= MAX_PREFIX_FAMILY { - for index in extending { - offer(index, CandidateRule::CatalogPrefix); - } - } else { - suppressed_family.extend(extending); + if withheld.is_empty() { + for index in &extending { + offer(*index, CandidateRule::CatalogPrefix); } - // One pass, carrying the character count forward. Recomputing - // `chars().count()` per prefix made this quadratic in the name length, - // and the source parser admits 4 KiB fields. + } + + // Weaker rules must not reinstate what the prefix pass withheld. A token + // shared across a family *is* the family, and re-listing 25 of them is the + // arbitrary slice the withholding exists to prevent — reachable whenever + // the family stays under the common-token threshold, as 30 rows in a + // 330-master catalog do. + if !entity.key.is_empty() { + // One pass over character boundaries; recomputing a prefix length per + // split made this quadratic in a field the source parser admits at 4 KiB. for (characters, (split, _)) in entity.key.char_indices().enumerate() { if characters < MIN_PREFIX_KEY_CHARS { continue; } - if let Some(holders) = catalog.by_key.get(&entity.key[..split]) { - for index in holders { - offer(*index, CandidateRule::SourcePrefix); - } + for index in catalog + .by_key + .get(&entity.key[..split]) + .into_iter() + .flatten() + .filter(|index| !withheld.contains(index)) + { + offer(*index, CandidateRule::SourcePrefix); } } } @@ -1042,26 +1064,28 @@ fn collect_candidates( if catalog.common_tokens.contains(&token) { continue; } - if let Some(holders) = catalog.by_token.get(&token) { - for index in holders { - offer(*index, CandidateRule::SharedToken); - } + for index in catalog + .by_token + .get(&token) + .into_iter() + .flatten() + .filter(|index| !withheld.contains(index)) + { + offer(*index, CandidateRule::SharedToken); } } - // The reported total is the union: a suppressed family and the candidates - // still worth listing are not necessarily the same masters, so taking the - // larger of the two counts would under-report what the name actually - // reaches. + // The total is the union: a withheld family and the candidates still worth + // listing are not necessarily the same masters, so the larger of the two + // counts would under-report what the name reaches. let found = best .keys() .copied() - .chain(suppressed_family) + .chain(withheld) .collect::>() .len(); - // Indices, not names. Cloning every match before the cap and the budget - // discarded most of the work for an entity whose identifier is shared by - // many rows, and an admitted draft repeats that per entry. + // Indices, not names — cloning every match before the cap and the budget + // discarded most of the work, once per entry of an admitted draft. (best.into_iter().collect(), found) } 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 7151ad1f..e46466d1 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 @@ -677,7 +677,7 @@ fn the_listing_variant_says_what_an_absent_candidate_means() { let listed = ledgers(&["ALPHA SALE", "ALPHA SALES", "SALES - ALPHA", "Beta Supply"]); let binding = bind_one_name(&listed, "ALPHA"); let candidates = &binding.unresolved().expect("unbound").candidates; - assert!(matches!(candidates, Candidates::Listed(_))); + assert!(matches!(candidates, Candidates::Listed { .. })); assert_eq!(candidates.found(), 3); } @@ -686,7 +686,7 @@ fn only_an_incomplete_listing_may_withhold_an_absence() { // The predicate a consumer needs before reporting "nothing like this is // present". `None` permits that conclusion; the other two forbid it. assert!(!Candidates::None.is_incomplete()); - assert!(!Candidates::Listed(Vec::new()).is_incomplete()); + assert!(!Candidates::Listed { listed: Vec::new() }.is_incomplete()); assert!(Candidates::Withheld { found: 30 }.is_incomplete()); assert!(Candidates::Truncated { listed: Vec::new(), @@ -731,6 +731,38 @@ fn a_prefix_matching_a_whole_family_is_counted_and_deliberately_not_listed() { assert!(unresolved.candidates.is_incomplete()); } +#[test] +fn a_weaker_rule_cannot_reinstate_a_withheld_family() { + // A token shared across a 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 was re-offering exactly the + // rows the prefix pass had withheld, restoring the arbitrary capped slice + // the withholding exists to prevent. + let mut names = (0..30) + .map(|index| format!("Acme Branch {index:03}")) + .collect::>(); + names.extend((0..300).map(|index| format!("Unrelated Ledger {index:03}"))); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("valid"); + // The scenario only exercises the path while the token stays + // discriminating: 10% of 330 is 33, and a 30-row family sits below it. + assert!( + 30 <= names.len() * COMMON_TOKEN_PERCENT / 100, + "the family would be suppressed as a common token, proving nothing" + ); + + let binding = bind_one_name(&catalog, "Acme Branch"); + let unresolved = binding.unresolved().expect("unbound"); + assert_eq!(reason(&binding), UnboundReason::NoDiscriminatingCandidate); + assert!(unresolved.candidates.listed().is_empty()); + assert_eq!(unresolved.candidates.found(), 30); + + // A decisive rule still reaches a family member on its own evidence: the + // whole key separates that one from its siblings, which is the difference + // between withholding a family and hiding a match. + let exact = bind_one_name(&catalog, "Acme Branch 017"); + assert_eq!(exact.bound_name(), Some("Acme Branch 017")); +} + #[test] fn a_family_within_the_bound_is_still_listed_in_full() { let names = (0..MAX_PREFIX_FAMILY) @@ -981,6 +1013,32 @@ fn reason_and_error_codes_are_stable_and_safe() { ); } +#[test] +fn every_unresolved_shape_survives_serialization() { + // A newtype variant under internal tagging cannot carry a sequence, and it + // failed at runtime on the *most common* unresolved result while the other + // three variants serialized fine. No test caught it because none had ever + // serialized an `Unresolved` — only a `Bound`. + let listed = ledgers(&["ALPHA SALE", "ALPHA SALES", "SALES - ALPHA", "Beta Supply"]); + let family = (0..MAX_PREFIX_FAMILY + 5) + .map(|index| format!("ALPHAGROUP UNIT {index:02}")) + .collect::>(); + let family = MasterCatalog::new(MasterClass::Ledger, &family).expect("valid"); + let missing = ledgers(&["Alpha Traders", "Beta Supply"]); + + for (label, binding) in [ + ("listed", bind_one_name(&listed, "ALPHA")), + ("withheld", bind_one_name(&family, "ALPHAGROUP")), + ("none", bind_one_name(&missing, "Zeta Placeholder")), + ] { + let json = serde_json::to_string(&binding) + .unwrap_or_else(|error| panic!("{label} failed to serialize: {error}")); + let back: EntityBinding = serde_json::from_str(&json) + .unwrap_or_else(|error| panic!("{label} failed to deserialize: {error}")); + assert_eq!(back, binding, "{label} did not round-trip"); + } +} + #[test] fn a_bound_status_serializes_without_a_score_field() { let catalog = ledgers(&["Alpha Traders"]); From 044a7d580421ac3c618e64dbf5e52448a3dfb8a0 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 03:20:59 +0530 Subject: [PATCH 15/91] Test periods on the raw token, and let conflicting identifiers outrank a name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/master_binding.rs | 96 +++++++++---------- .../src/master_binding_tests.rs | 36 +++++++ 4 files changed, 87 insertions(+), 51 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 5bee5cef..2cea617d 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": "36b93b23265867e8b69a3ff288721e1a783820d88ef49d146a7e2fd26f7b8969", + "compatibility_surface_sha256": "0edebb5724ed17cc3a44bf44ce1cc1c03eb280c5d13509c0d70f9e94043a2e64", "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 5a94d598..9fc6673f 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "d0ef94689fd4f0d91649beb105e68aa46a64e1c6e73f116e3e8a7ea01bf18bc3" + "sha256": "1dac3c154b5234899d6715753fa639332cd6ccdb0869878985b9a4278e977eb1" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "36b93b23265867e8b69a3ff288721e1a783820d88ef49d146a7e2fd26f7b8969" + "manifest_sha256": "0edebb5724ed17cc3a44bf44ce1cc1c03eb280c5d13509c0d70f9e94043a2e64" } \ No newline at end of file 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 fe6d3c55..636814f7 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -833,8 +833,12 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity, budget: &mut usize) // // Found by seeding two live ledgers that share an embedded number. No // fabricated fixture had produced the combination. - let identifier_points_elsewhere = !identifier_conflict - && identifier_matches.len() == 1 + // A byte-exact name survives an identifier that is merely *shared* — the + // ambiguous set still contains the master the name spells, so the name is + // what separates it from its siblings. It does not survive identifiers that + // all point somewhere else: that is conflicting evidence, however many of + // them there are, and preferring the name silently discards it. + let identifier_points_elsewhere = !identifier_matches.is_empty() && exact.is_some_and(|index| !identifier_matches.contains(&index)); let status = if identifier_points_elsewhere { unresolved_status( @@ -1207,7 +1211,7 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro if canonical.len() >= MIN_CODE_IDENTIFIER_CHARS && digits >= MIN_CODE_IDENTIFIER_DIGITS && letters >= 2 - && !is_period_label(&canonical) + && !is_period(token) { identifiers.insert(Identifier { kind: IdentifierKind::Code, @@ -1228,7 +1232,7 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro let digits = run.chars().filter(char::is_ascii_digit).collect::(); if digits.len() >= MIN_NUMERIC_IDENTIFIER_DIGITS && !is_plausible_date(&digits) - && !is_year_range(run) + && !is_period(run) { identifiers.insert(Identifier { kind: IdentifierKind::Numeric, @@ -1244,23 +1248,40 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro } /// A period label identifies a period, not a party or an item. Two unrelated -/// ledgers routinely share one — `Purchases FY2025` and `Sales FY2025`, -/// `Purchases SEPTEMBER2025` and `Sales SEPTEMBER2025` — and identifier-first -/// matching would bind the source to whichever exists before comparing names. +/// ledgers routinely share one, and identifier-first matching would bind the +/// source to whichever exists before it ever compared the names. /// -/// The test is on the **numbers**, not on the words: a token is a period label -/// when it carries at least one number and **every** number in it reads as a -/// year or a small ordinal. Capping the length of the alphabetic run was the -/// previous attempt and it kept losing to longer spellings — `APR2025` was -/// caught while `SEPTEMBER2025` and `2025QUARTER1` walked through. A month name -/// can be any length; a year cannot. +/// Applied to the **raw token**, because operators write ranges with the very +/// separators canonicalization strips: `FY2025-26` fuses to `FY202526`, whose +/// six-digit run reads as no period at all, and the label walked straight into +/// being a code. Splitting on the separator first keeps `FY2025` and `26` +/// legible as what they are. /// -/// An identity-bearing code survives this because its digits do not read as -/// periods: `PH01AB00` carries `00`, `AB12345678` carries an eight-digit run, -/// and a registration number carries something no calendar would produce. Like +/// The test is on the **numbers**, not the words: every part carries only +/// alphabetic markers and numbers that read as a year or a small ordinal, and +/// at least one number appears. Capping the length of the alphabetic run was an +/// earlier attempt that kept losing to longer spellings — a month name can be +/// any length; a year cannot. +/// +/// An identity-bearing code survives: `PH-01A-B00` splits to a `PH` carrying no +/// number at all, and `AB12345678` holds a run no calendar would produce. Like /// every exclusion here it can only make a bind *less* likely. -fn is_period_label(canonical: &str) -> bool { - let mut has_number = false; +fn is_period(token: &str) -> bool { + let mut any_number = false; + for part in token.split(['-', '/']) { + let canonical = part + .chars() + .filter(char::is_ascii_alphanumeric) + .map(|character| character.to_ascii_uppercase()) + .collect::(); + if canonical.is_empty() || !part_reads_as_period(&canonical, &mut any_number) { + return false; + } + } + any_number +} + +fn part_reads_as_period(canonical: &str, any_number: &mut bool) -> bool { let mut rest = canonical; while !rest.is_empty() { let alphabetic = rest.starts_with(|character: char| character.is_ascii_alphabetic()); @@ -1272,39 +1293,18 @@ fn is_period_label(canonical: &str) -> bool { if alphabetic { continue; } - if !reads_as_period_number(run) { + let value = run.parse::().unwrap_or(u32::MAX); + let reads_as_period = match run.len() { + 1 | 2 => (1..=99).contains(&value), + 4 => (1900..=2199).contains(&value), + _ => false, + }; + if !reads_as_period { return false; } - has_number = true; - } - has_number -} - -/// A year, or a small ordinal such as a month or quarter. -fn reads_as_period_number(run: &str) -> bool { - let value = run.parse::().unwrap_or(u32::MAX); - match run.len() { - 1 | 2 => (1..=99).contains(&value), - 4 => (1900..=2199).contains(&value), - _ => false, - } -} - -/// `2025-2026` and `2025/2026` are fiscal years, which two unrelated ledgers -/// share as routinely as they share a month. Stripping the separator turned -/// them into an eight-digit run that no calendar-date reading rejects, so the -/// range has to be recognized before the digits are fused. -fn is_year_range(run: &str) -> bool { - let mut halves = run.split(['-', '/']); - match (halves.next(), halves.next(), halves.next()) { - (Some(first), Some(second), None) => [first, second].iter().all(|half| { - half.len() == 4 - && half - .parse::() - .is_ok_and(|year| (1900..=2199).contains(&year)) - }), - _ => false, + *any_number = true; } + true } /// An eight-digit run that reads as a calendar date in any order this project/// An eight-digit run that reads as a calendar date in any order this project/// An eight-digit run that reads as a calendar date in any order this project 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 e46466d1..55fa33a3 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 @@ -516,6 +516,12 @@ fn a_fiscal_period_label_is_not_an_identity_bearing_code() { "SEPTEMBER2025", "2025QUARTER1", "DECEMBER2026", + // Ranges: operators write these with the very separators that + // canonicalization strips, so the test has to see the raw token. + "FY2025-26", + "FY2025/26", + "2025-2026", + "APR2025-MAR2026", ] { assert!( entity(&format!("Purchases {label}")) @@ -589,6 +595,36 @@ fn a_token_carrying_letters_never_yields_a_standalone_number() { assert_eq!(entity("Party (5550001001)").identifiers().len(), 1); } +#[test] +fn conflicting_identifiers_outrank_a_byte_exact_name_but_a_shared_one_does_not() { + // Two hints selecting two other masters is conflicting evidence, and + // binding the name silently discarded it. A *shared* identifier is + // different: the ambiguous set still contains the master the name spells, + // so the name is what separates it from its siblings. + let catalog = ledgers(&["ACME", "BETA 11111111", "GAMMA 22222222"]); + let source = + SourceEntity::with_identifier_hints(0, "ACME", ["11111111", "22222222"]).expect("valid"); + let report = bound(&catalog, &[source]); + let binding = &report.entities()[0]; + assert_eq!(reason(binding), UnboundReason::IdentifierNameConflict); + // Every master the evidence reached is offered, so the operator sees the + // disagreement rather than one side of it. + assert_eq!( + candidate_names(binding), + ["BETA 11111111", "GAMMA 22222222", "ACME"] + ); + + // The shared-identifier case must keep binding. + let shared = ledgers(&[ + "MB PARTY DELTA (5550001009)", + "MB PARTY EPSILON (5550001009)", + ]); + assert_eq!( + bind_one_name(&shared, "MB PARTY DELTA (5550001009)").bound_name(), + Some("MB PARTY DELTA (5550001009)") + ); +} + #[test] fn a_fiscal_year_range_is_a_period_not_an_account_number() { // `2025-2026` strips to an eight-digit run that no calendar reading From bc802226cc09685e3d5ebc67a8f4f4b8e30f15b9 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 03:26:44 +0530 Subject: [PATCH 16/91] Treat a non-ASCII name as a name, and a mask as identifying nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/tally/TEST_CORPUS.md | 10 +++++++ .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +-- .../bridge-tally-core/src/master_binding.rs | 26 ++++++++++++++++++- .../src/master_binding_tests.rs | 25 ++++++++++++++++++ 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/docs/tally/TEST_CORPUS.md b/docs/tally/TEST_CORPUS.md index 199b2d15..1f884162 100644 --- a/docs/tally/TEST_CORPUS.md +++ b/docs/tally/TEST_CORPUS.md @@ -351,6 +351,16 @@ bytes, so it cannot support any claim about the exact bytes a real instance rece ## 9. Master-binding ledgers in `BRIDGE CORPUS OPENING` +**VERIFIED 2026-09-10** for the seeding and the coverage counts; the binding behaviour built on +them is **PARTIAL**. Scope of each, so neither is read for more than it covers: + +| claim | confidence | what establishes it | +| --- | --- | --- | +| The ten ledgers exist in that company and nowhere else | **VERIFIED** | `CREATED=10, ALTERED=0, ERRORS=0`, then a readback of the ledger list naming all ten, plus a readback of a guard company showing none | +| No book carried an embedded identifier before this | **VERIFIED** | all 16 loaded companies read through the `StandardLedgerCatalogV1` request, responses written to files and parsed from the files; 470 names, 0 numeric and 1 code identifier | +| The identifier rule behaves correctly against live-read names | **PARTIAL** | exercised against these ten seeded names only, on one instance, one licence tier, one Tally build. Fabricated *source* names against live *catalogue* names — no real source document has been bound end to end | +| Binding is safe on catalogues generally | **UNVERIFIED** | no engagement has run through this code path; the mutation sweep is fabricated mutations of live names, not observed operator input | + **Added 2026-09-10.** Ten ledgers prefixed `MB `, seeded so the master-binding identifier rule has live coverage. Before this, **no book on either instance carried an embedded identifier**: across 470 live ledger names read from all 16 loaded companies, diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 2cea617d..48fe68b0 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": "0edebb5724ed17cc3a44bf44ce1cc1c03eb280c5d13509c0d70f9e94043a2e64", + "compatibility_surface_sha256": "0adf444c0af9ea4b8937f2a061b25e8e2ef82cbfcf45048deccca62f3950a72f", "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 9fc6673f..d2603895 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "1dac3c154b5234899d6715753fa639332cd6ccdb0869878985b9a4278e977eb1" + "sha256": "457e63b990e57a55e8dd2f448b03ca70aeea9cffd31dd8a8dca15841098cb1fa" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "0edebb5724ed17cc3a44bf44ce1cc1c03eb280c5d13509c0d70f9e94043a2e64" + "manifest_sha256": "0adf444c0af9ea4b8937f2a061b25e8e2ef82cbfcf45048deccca62f3950a72f" } \ No newline at end of file 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 636814f7..0921d977 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -1212,6 +1212,7 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro && digits >= MIN_CODE_IDENTIFIER_DIGITS && letters >= 2 && !is_period(token) + && !is_masked(&canonical) { identifiers.insert(Identifier { kind: IdentifierKind::Code, @@ -1223,7 +1224,12 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro // reach an unrelated `Bank 12345678` through the one-letter gap that // the code test rejects — a token either identifies by its whole shape // or not at all. - if letters > 0 { + // + // The test is **Unicode alphabetic**, not ASCII. The books observed + // here carry Devanagari, Tamil and Bengali ledger names, and an + // ASCII-only guard read `पार्टी12345678` as digits standing alone, + // binding a party to an unrelated `Bank 12345678`. + if token.chars().any(char::is_alphabetic) { continue; } for run in token.split(|character: char| { @@ -1247,6 +1253,24 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro Ok(identifiers.into_iter().collect()) } +/// A masked value exposes a non-unique suffix and identifies nothing. +/// +/// `XXXXX1234X` clears every length and composition test — ten characters, six +/// letters, four digits, no period — while the only information in it is a last +/// four that any number of parties share. Two unrelated ledgers carrying the +/// same mask would bind to each other. +/// +/// Recognized by its letters being a single repeated character, which is what a +/// mask is and what an identity-bearing code never is: `PH01AB00` and a +/// registration number both carry distinct letters. +fn is_masked(canonical: &str) -> bool { + let mut letters = canonical.chars().filter(char::is_ascii_alphabetic); + match letters.next() { + Some(first) => letters.all(|letter| letter == first), + None => false, + } +} + /// A period label identifies a period, not a party or an item. Two unrelated /// ledgers routinely share one, and identifier-first matching would bind the /// source to whichever exists before it ever compared the names. 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 55fa33a3..e03f7078 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 @@ -625,6 +625,31 @@ fn conflicting_identifiers_outrank_a_byte_exact_name_but_a_shared_one_does_not() ); } +#[test] +fn a_non_ascii_name_beside_digits_is_still_a_name() { + // The observed books carry Devanagari, Tamil and Bengali ledger names. An + // ASCII-only letter guard read `पार्टी12345678` as digits standing alone + // and bound a party to an unrelated bank ledger. + let party = "\u{92a}\u{93e}\u{930}\u{94d}\u{91f}\u{940}12345678"; + assert!(entity(party).identifiers().is_empty()); + let catalog = ledgers(&["Bank 12345678", "Beta Supply"]); + assert_eq!(bind_one_name(&catalog, party).bound_name(), None); +} + +#[test] +fn a_masked_value_identifies_nothing() { + // `XXXXX1234X` clears every length and composition test while carrying only + // a last four that any number of parties share. + assert!(entity("Purchases XXXXX1234X").identifiers().is_empty()); + let catalog = ledgers(&["Sales XXXXX1234X", "Beta Supply"]); + assert_eq!( + bind_one_name(&catalog, "Purchases XXXXX1234X").bound_name(), + None + ); + // Distinct letters are what an identity-bearing code has and a mask does not. + assert_eq!(entity("Item PH01AB00").identifiers().len(), 1); +} + #[test] fn a_fiscal_year_range_is_a_period_not_an_account_number() { // `2025-2026` strips to an eight-digit run that no calendar reading From 1406adaf437b6c30fb45bd96fdf53637bf017927 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 09:04:22 +0530 Subject: [PATCH 17/91] Keep provenance per identifier rather than flattening it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +-- .../bridge-tally-core/src/master_binding.rs | 33 ++++++++++++++----- .../src/master_binding_tests.rs | 17 +++++++++- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 48fe68b0..8591df77 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": "0adf444c0af9ea4b8937f2a061b25e8e2ef82cbfcf45048deccca62f3950a72f", + "compatibility_surface_sha256": "7b4301713e4f9d3f888dc7650c8f59f742b330763bdc916d54a80fc5bdebcd0a", "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 d2603895..62aa9f76 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "457e63b990e57a55e8dd2f448b03ca70aeea9cffd31dd8a8dca15841098cb1fa" + "sha256": "9175af95eabdf88a72475e23ef601ba11154c2abf98613ae1e30f26bafa49aa6" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "0adf444c0af9ea4b8937f2a061b25e8e2ef82cbfcf45048deccca62f3950a72f" + "manifest_sha256": "7b4301713e4f9d3f888dc7650c8f59f742b330763bdc916d54a80fc5bdebcd0a" } \ No newline at end of file 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 0921d977..6b718bbe 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -809,14 +809,21 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity, budget: &mut usize) // Rule one: the identifier is the key, the name is a hint. A name // comparison on a pair that carries a decisive identifier is not merely // weaker evidence, it is actively misleading. + // Which masters *each* identifier reached, not merely which masters were + // reached. Flattening the two loses the only fact that separates a number + // shared by several masters from several numbers pointing at different + // ones, and those need opposite answers. + let mut per_identifier: Vec> = Vec::new(); let mut identifier_matches = BTreeSet::new(); let mut identifier_conflict = false; for identifier in &entity.identifiers { if let Some(holders) = catalog.by_identifier.get(identifier) { - if holders.len() > 1 { + let reached = holders.iter().copied().collect::>(); + if reached.len() > 1 { identifier_conflict = true; } - identifier_matches.extend(holders.iter().copied()); + identifier_matches.extend(reached.iter().copied()); + per_identifier.push(reached); } } @@ -833,13 +840,21 @@ fn bind_one(catalog: &MasterCatalog, entity: &SourceEntity, budget: &mut usize) // // Found by seeding two live ledgers that share an embedded number. No // fabricated fixture had produced the combination. - // A byte-exact name survives an identifier that is merely *shared* — the - // ambiguous set still contains the master the name spells, so the name is - // what separates it from its siblings. It does not survive identifiers that - // all point somewhere else: that is conflicting evidence, however many of - // them there are, and preferring the name silently discards it. - let identifier_points_elsewhere = !identifier_matches.is_empty() - && exact.is_some_and(|index| !identifier_matches.contains(&index)); + // A byte-exact name survives an identifier that is merely *shared*: that + // one identifier reached the master the name spells along with its + // siblings, and the name is what separates them. It does not survive an + // identifier that reached somewhere else entirely — that is disagreement, + // and preferring the name silently discards it. + // + // The test is per identifier, not over their union. Asking whether the + // union contains the exact master answers the shared case correctly and the + // mixed case wrongly: `ACME 11111111` with a hint reaching `BETA 22222222` + // has the exact master in the union while one identifier plainly disagrees. + let identifier_points_elsewhere = exact.is_some_and(|index| { + per_identifier + .iter() + .any(|reached| !reached.contains(&index)) + }); let status = if identifier_points_elsewhere { unresolved_status( catalog, 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 e03f7078..6bcedc43 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 @@ -614,7 +614,9 @@ fn conflicting_identifiers_outrank_a_byte_exact_name_but_a_shared_one_does_not() ["BETA 11111111", "GAMMA 22222222", "ACME"] ); - // The shared-identifier case must keep binding. + // The shared-identifier case must keep binding: one identifier reached the + // master the name spells along with its sibling, and the name separates + // them. let shared = ledgers(&[ "MB PARTY DELTA (5550001009)", "MB PARTY EPSILON (5550001009)", @@ -623,6 +625,19 @@ fn conflicting_identifiers_outrank_a_byte_exact_name_but_a_shared_one_does_not() bind_one_name(&shared, "MB PARTY DELTA (5550001009)").bound_name(), Some("MB PARTY DELTA (5550001009)") ); + + // The mixed case, which a union test answers wrongly: the exact master is + // in the union because its own number is one of the identifiers, while a + // second identifier plainly reaches somewhere else. Provenance per + // identifier is the only thing that separates this from the shared case. + let mixed = ledgers(&["ACME 11111111", "BETA 22222222"]); + let source = + SourceEntity::with_identifier_hints(0, "ACME 11111111", ["22222222"]).expect("valid"); + let report = bound(&mixed, &[source]); + assert_eq!( + reason(&report.entities()[0]), + UnboundReason::IdentifierNameConflict + ); } #[test] From 34212664af457f43d9ef8693ec658e0b3390a545 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 09:11:07 +0530 Subject: [PATCH 18/91] Reject mask punctuation before extracting a numeric identifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 ++-- .../bridge-tally-core/src/master_binding.rs | 16 +++++++++++++++- .../src/master_binding_tests.rs | 18 ++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 8591df77..59f3d563 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": "7b4301713e4f9d3f888dc7650c8f59f742b330763bdc916d54a80fc5bdebcd0a", + "compatibility_surface_sha256": "468c5b38d5300be99b9e0761e929fbecba69ea89b505e35396b8519a129c3a7d", "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 62aa9f76..51716d05 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "9175af95eabdf88a72475e23ef601ba11154c2abf98613ae1e30f26bafa49aa6" + "sha256": "88a88e1a268d950b68929c0df0729e86f556f60152be2c935789983f319376f7" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -846,5 +846,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "7b4301713e4f9d3f888dc7650c8f59f742b330763bdc916d54a80fc5bdebcd0a" + "manifest_sha256": "468c5b38d5300be99b9e0761e929fbecba69ea89b505e35396b8519a129c3a7d" } \ No newline at end of file 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 6b718bbe..c2db73df 100644 --- a/src-tauri/crates/bridge-tally-core/src/master_binding.rs +++ b/src-tauri/crates/bridge-tally-core/src/master_binding.rs @@ -1244,7 +1244,7 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro // here carry Devanagari, Tamil and Bengali ledger names, and an // ASCII-only guard read `पार्टी12345678` as digits standing alone, // binding a party to an unrelated `Bank 12345678`. - if token.chars().any(char::is_alphabetic) { + if token.chars().any(char::is_alphabetic) || is_mask_punctuated(token) { continue; } for run in token.split(|character: char| { @@ -1268,6 +1268,20 @@ fn extract_identifiers(value: &str) -> Result, MasterBindingErro Ok(identifiers.into_iter().collect()) } +/// A value written with mask punctuation is partial by construction, so the +/// digits it does expose are a suffix rather than the number. +/// +/// `********12345678` split cleanly on the asterisks and yielded its visible +/// eight digits as though they were the whole account. `is_masked` guards the +/// code branch only, because a mask spelled with letters is caught by the +/// letter test; a mask spelled with punctuation reaches the numeric branch, +/// where every non-digit is an ordinary delimiter. +fn is_mask_punctuated(token: &str) -> bool { + token + .chars() + .any(|character| matches!(character, '*' | '#' | '\u{2022}' | '\u{00d7}')) +} + /// A masked value exposes a non-unique suffix and identifies nothing. /// /// `XXXXX1234X` clears every length and composition test — ten characters, six 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 6bcedc43..f7886c45 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 @@ -663,6 +663,24 @@ fn a_masked_value_identifies_nothing() { ); // Distinct letters are what an identity-bearing code has and a mask does not. assert_eq!(entity("Item PH01AB00").identifiers().len(), 1); + + // A mask spelled with punctuation reaches the numeric branch instead, where + // every non-digit is an ordinary delimiter — so `********12345678` split + // cleanly and offered its visible suffix as though it were the account. + for masked in ["Purchases ********12345678", "Purchases ####12345678"] { + assert!( + entity(masked).identifiers().is_empty(), + "{masked} exposed its suffix as an identifier" + ); + } + let punctuated = ledgers(&["Sales ********12345678", "Beta Supply"]); + assert_eq!( + bind_one_name(&punctuated, "Purchases ********12345678").bound_name(), + None + ); + // Ordinary punctuation around a whole number is not a mask. + assert_eq!(entity("Party (5550001001)").identifiers().len(), 1); + assert_eq!(entity("Party 5550001-002").identifiers().len(), 1); } #[test] From 3fbbc41f279e3700607a6320ea18f5ed3c779a32 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 00:32:59 +0530 Subject: [PATCH 19/91] Decide which proposed vouchers are already in the book MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tally has no idempotency: re-sending a voucher creates a second one (TALLY_PROTOCOL_REFERENCE.md 9.3). Two sales imports were blocked on the same day by the question Bridge could not answer — one book already held most of the proposed invoices, hand-keyed, and the other batch waited a day for a Day Book to arrive by hand. ADR 0017 fixes the contract and bridge_tally_core::book_presence implements it, deterministically and with no model. Only identity can produce `present`: a shared REMOTEID, or a voucher number on a voucher type the caller declares Manual, unique on both sides and within an observed voucher type. `absent` requires that no rule produced any candidate, in a window that could only be built from a complete read and that covers every proposed date. Everything between is `possibly_present`, which authorises nothing, names no match, and carries no score. A `present` voucher reports the date, amount and party its source disagrees with, which is how a hand-keyed invoice short by one GST head surfaces at all. Party matching is not reinvented: it is master_binding, whose ambiguous candidates are all compared rather than one of them chosen. Indexing the window also reports, for free, voucher numbers that identify more than one book voucher — a real filed-return problem found by hand in one client book. Exposed as the MCP tool `voucher_presence`. The desktop source-draft flow is deliberately not wired: its rows carry neither a voucher number nor a party, so both deciding keys are absent there today. MAX_SURFACE_FILES rises 211 -> 213 for the two new pinned files, with the reason recorded beside the constant. Co-Authored-By: Claude Opus 5 --- docs/adr/0017-voucher-presence-authority.md | 320 ++++ .../compatibility/compatibility-surface.json | 8 + .../bridge-tally-core/src/book_presence.rs | 1368 +++++++++++++++++ .../src/book_presence_tests.rs | 1161 ++++++++++++++ src-tauri/crates/bridge-tally-core/src/lib.rs | 1 + src-tauri/src/agent.rs | 3 + src-tauri/src/agent_catalog.rs | 18 + src-tauri/src/agent_presence.rs | 276 ++++ src-tauri/src/agent_presence_tests.rs | 404 +++++ src-tauri/src/agent_tests.rs | 10 +- tools/bridge-tally-compatibility/src/lib.rs | 12 +- 11 files changed, 3579 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0017-voucher-presence-authority.md create mode 100644 src-tauri/crates/bridge-tally-core/src/book_presence.rs create mode 100644 src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs create mode 100644 src-tauri/src/agent_presence.rs create mode 100644 src-tauri/src/agent_presence_tests.rs diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md new file mode 100644 index 00000000..f1d235f8 --- /dev/null +++ b/docs/adr/0017-voucher-presence-authority.md @@ -0,0 +1,320 @@ +# ADR 0017: Voucher presence is decided by identity, never by resemblance, and the doubt is never resolved + +## Status + +Accepted for the shared presence contract in `bridge-tally-core`, consumed by +the agent/MCP layer. It decides, for a set of proposed vouchers, which are +already in one company's book **within one observed window**. It selects +nothing, writes nothing, and dispatches nothing. Master creation, voucher +generation, posting, deletion, and any scored or model-assisted matching remain +rejected without separate evidence. + +## Context + +**Tally has no idempotency.** Re-sending an identical voucher payload with the +same `VOUCHERNUMBER` creates a second voucher — verified, and recorded in +[`TALLY_PROTOCOL_REFERENCE.md` §9.3](../tally/TALLY_PROTOCOL_REFERENCE.md). +Nothing in the protocol dedupes on the client's behalf. Duplicated invoices +inside a filed GST period are a return problem, not a cosmetic one. + +So before any generated batch can be imported, one question has to be answered +and Bridge cannot answer it: + +- A dealership's August sales: twenty invoices in the source report, **fifteen + already keyed in by hand.** That was discovered only because the operator + happened to send a Day Book screenshot. Without it the run would have posted + twenty and duplicated fifteen. +- A trading firm's August sales: **forty-nine vouchers generated, validated, + arithmetic-checked, and un-importable at the end of the day**, waiting for a + Day Book to arrive by hand the next morning. + +Both books were **hand-keyed**, so no voucher in either carried a `REMOTEID` +Bridge had written. Both engagements were blocked on the same day. + +The question also has diagnostic value on its own. One of those books already +held **twenty-five invoices in a single month sharing voucher numbers** — +visible in Tally's own `Duplicate Voucher No.` exceptions before any import ran. +Indexing a book window by voucher number finds that for free. + +Bridge already performs the read this needs: `vouchers` returns a literal-window +voucher list with date, voucher type, voucher number, party ledger, entry +ledgers and amounts. What is missing is the comparison and, more importantly, +the contract that says what a comparison is allowed to conclude. + +### Why the obvious keys are each insufficient + +| Candidate key | Where it holds | Where it fails | +| --- | --- | --- | +| `REMOTEID` | Vouchers Bridge imported. Reliable. | Absent from every hand-keyed voucher — which is both blocked engagements. | +| `VOUCHERNUMBER` | Voucher types numbered **Manual**. One book preserved a long alphanumeric invoice series verbatim, another a plain three-digit bill number. | Under **Automatic** numbering Tally *discards* the supplied number (§9.8), so a number-based key is silently ineffective. And a book that does not set `PREVENTDUPLICATES` can hold the same number twice — one did, twenty-five times. | +| date + party + amount | Needs neither of the above. | Collides. In one month of real data `141,600`, `177,000` and `16,992` each recurred across *unrelated* parties. | + +No single key decides. A contract that pretends one does will be wrong in the +field, quietly. + +## Decision + +Presence is a **pure, deterministic function in `bridge-tally-core`** over (one +observed window of a company's book, the ledger catalog observed for that same +company, the vouchers a source document proposes). It performs no I/O, holds no +transport handle, calls no model, and depends on nothing above +`bridge-tally-primitives` and `master_binding`. + +### 1. Party matching is not reinvented — it is `master_binding` + +"Is this the same customer" is the question ADR 0016 already answers, and there +must not be a second answer to it. A proposal's party name is bound to the +observed ledger catalog through `master_binding::bind`, and the result is +consumed as-is: + +- **`Bound`** — the bound catalog name is the one name party rules compare + against. +- **`Ambiguous`** — *every* candidate name is compared against. Using the whole + candidate set can only produce more resemblance, never less, which is the + safe direction here; picking one of them would be the auto-resolution ADR + 0016 forbids. +- **`Unmatched`** — no name is compared. A party with no ledger and nothing + resembling one cannot be carrying a posted voucher in this book, so + party-independent rules are all that remain and `Absent` stays available. + +Two binding outcomes withhold `Absent` outright: `NoDiscriminatingCandidate` +(a name family that is deliberately not listed) and a truncated candidate list. +In both, names that might have matched were never compared, and reporting +"absent" off an incomplete comparison is the failure this ADR exists to prevent. + +### 2. A window is a *claim about a window*, and it must be complete + +`BookWindow::observed` is a boundary parse. It refuses, rather than degrades, +on: + +- **a read that was not complete** — `WindowIncomplete`. A window too dense to + read, or one whose emptiness was only partially corroborated, is not "no + match found". This is the single most dangerous confusion available here and + it is a typed error, not a flag a caller may overlook; +- a window that does not **cover** every proposed date — `WindowDoesNotCover`. + A voucher outside the window is invisible, so a verdict over it would be + fiction; +- a voucher dated outside the window's own range, a duplicate voucher key, an + invalid range, or a window past its bound. + +Every verdict is therefore explicitly scoped to the window the report carries. +`Absent` means *absent from this window* — it never means "absent from the +book". A voucher keyed in September against an August window is not visible, +and widening the window is the caller's decision, made in the open. + +### 3. The numbering method is declared, and its absence is an error + +The decisive power of a voucher number depends entirely on the voucher type's +numbering method (§9.8), and Bridge has **no qualified voucher-type read**: the +protocol reference records that a numbering preflight "would require a +separately observed voucher-type read contract". + +So the method is supplied by the caller as an explicit declaration per voucher +type, with three values — `Manual`, `Automatic`, `Unknown` — and a voucher type +named by a proposal but absent from the declaration is +`NumberingMethodUndeclared`, a typed error. A silently defaulted declaration +would silently decide whether the strongest available key is usable at all. +`Unknown` remains fully legal and is the honest answer most of the time; it +simply demotes the number from identity to resemblance. + +This makes a real protocol fact operationally visible: on an automatically +numbered voucher type, Bridge will decide nothing, and will say so, rather than +matching on a number Tally threw away. + +### 4. Three statuses. The middle one is never resolved + +Per proposed voucher, exactly one of: + +| status | meaning | what it authorises | +| --- | --- | --- | +| `Present { matched, basis, differences }` | An identity key matched, uniquely on both sides | excluding this voucher from the import | +| `PossiblyPresent { reason, candidates, .. }` | Something resembles it, or something prevented a decision | **nothing** | +| `Absent` | No rule produced any candidate, in a window proven to cover it | including this voucher in the import | + +`PossiblyPresent` carries candidates labelled with the **rule that surfaced +each** — `SharedVoucherNumber`, `SameDatePartyAmount`, `SamePartyAmount`, +`SameDateAmount`, `SameDateParty` — ordered by rule and then by the book +voucher's own ordering. **No candidate is marked best, likely or preferred, and +no score is emitted anywhere.** + +This is not a stylistic echo of ADR 0016; it is the same defect being refused +twice. Bridge has already shipped a bug in exactly this family — a `near_miss` +status that carried a guessed `exact_live_spelling` in the same object, so +whichever field a consumer read first decided the outcome. A status does not +disarm a value printed beside it. Here the guard is structural: a +`PossiblyPresent` has no field that names a match, and `Present` is the only +variant that can carry one. + +### 5. Only identity produces `Present` + +Two bases, and nothing else: + +- **`RemoteId`** — the proposal and exactly one book voucher carry the same + `REMOTEID`, and no other proposal carries it. +- **`ManualVoucherNumber`** — the voucher type is declared `Manual`, and the + (voucher type, normalized number) pair selects **exactly one book voucher and + exactly one proposal**. Uniqueness on both sides is ADR 0016's rule 2, and it + is what makes the twenty-five-duplicates book safe: those numbers select more + than one voucher, so they decide nothing and surface as an ambiguity instead. + +Number comparison uses the same NFC / dash-and-quote / case / whitespace +comparison key as master binding, so a long alphanumeric invoice number and its +differently punctuated twin agree. + +Voucher types are compared on that key too, and a manual number decides only +**within an observed voucher type** — numbers are a per-type series, so a match +across types is a coincidence, not a series position. If a proposal's voucher +type is **not observed anywhere in the window**, type discriminates nothing, so +number matching widens to every observed type *and is demoted to a +resemblance*: it can surface candidates and can never produce `Present`. A book +carrying both `Part Sale` and `Parts Sale` is exactly why narrowing on an +unobserved type name would manufacture absence, and exactly why widening must +not be allowed to decide. + +A book voucher that is **cancelled or optional** never yields `Present`. It has +no accounting effect but does occupy its number, so a number that lands on one +is reported as `MatchedVoucherNotPosted` for a human. A struck-through and +re-issued bill is a real case, met four times in one month of one book. + +Date, amount and party are **never** a basis for `Present`. They are the keys +that measurably collide. + +### 6. `Present` reports what disagrees, and that is half the value + +A `Present` verdict compares the proposal against the book voucher it matched +and lists every difference in date, amount, or bound party. The match is on +identity, so a difference is not evidence against the match — it is a finding +about the book. + +This is not speculative: in one engagement an invoice was posted **₹36.13 +short** because one 9% GST head was dropped when it was keyed by hand, and its +voucher number still matched perfectly. Under this contract that invoice comes +back `Present` with an amount difference — precisely the report the client +needed and nobody had asked for. + +### 7. The error posture, stated + +The two errors are not symmetric, and the asymmetry is **detectability**, not +severity: + +- A false `Present` silently drops an invoice. Nothing records it. It is not in + Tally, not in the return, not in Bridge, and not in any exceptions report. + There is no artifact to find later. +- A false `Absent` creates a duplicate. Tally's own `Duplicate Voucher No.` + exceptions report surfaces it, and because Bridge wrote it, it carries + Bridge's `REMOTEID` — which is the key for the **only** correction path Tally + offers, since vouchers cannot be modified and deletion is by `REMOTEID` + (§9.7). A duplicate Bridge created is a duplicate Bridge can delete. + +**Therefore the bar for `Present` is set higher than the bar for `Absent`, and +both are set higher than a resemblance.** `Present` requires identity; +`Absent` requires that no rule produced any candidate at all. Doubt in either +direction lands in `PossiblyPresent`, which authorises nothing and is handed to +a person. + +The cost of this posture is operator review time. That is the intended cost: +the middle is where a human is genuinely faster than any rule, and the +alternative to reviewing it is an invisible omission or an invisible duplicate. + +### 8. What the book itself gives away + +Building the index makes three book-side observations free, and they are +reported alongside the verdicts rather than discarded: + +- `duplicate_numbers` — a (voucher type, number) that selects more than one + book voucher. This is the twenty-five-invoice finding, computed rather than + noticed. +- `unbalanced_vouchers` — a book voucher whose entries do not sum to zero. +- `unclaimed_book_vouchers` — how many vouchers in the window no proposal + matched. Counted only; listing them is a different report. + +A voucher's magnitude is the sum of its positive entry amounts, computed in +exact decimal. It is defined whether or not the voucher balances, so an +unbalanced book voucher still participates in every amount rule — it is +reported, never excluded, because excluding it would make `Absent` *more* +likely, which is the wrong direction. + +### 9. Totals prove the run + +`PresenceReport::totals()` reports `requested`, `present`, `possibly_present`, +and `absent`, with `requested == present + possibly_present + absent` asserted +by test. This is the same control-total discipline that proved every clean +import engagement, and it is what lets an operator reconcile a generated file +against a source document by count alone. + +### 10. A verdict is a proposal, not an approval + +The report names book vouchers by an opaque caller-supplied key and names +masters by observed name only. It holds no company GUID and grants no +authority. A caller acting on `Absent` still goes through the unchanged +build-and-approve path, whose write gate — byte-exact master names, one +human-approved batch — this ADR does not move. + +## Consequences + +- `bridge_tally_core::book_presence` is new and is the only implementation. The + MCP tool `voucher_presence` is its first consumer; it performs the existing + qualified ledger-catalogue and `vouchers` window reads, refuses to build a + window from a partial read, and shapes the report through the same party-name + marking and egress redaction as every other read result. +- Voucher numbers and voucher-type names fold through + `master_binding::comparison_key` — the *same* key master names use, now an + explicit crate-wide contract point owned by ADR 0016 rather than a private + helper. A second, subtly different normalizer is exactly the divergence that + ADR was written to end, and it would diverge silently: two folds agree on + every name anyone tests by hand and disagree on the punctuation nobody thinks + to try. This contract consumes that function and defines no fold of its own. +- **The desktop source-draft flow is deliberately not wired yet, and the reason + is a shape gap rather than a scheduling one.** A draft row carries a + `source_remote_id`, a date, a voucher type and entries — but no voucher + number and no party field, and its voucher type is restricted to Payment, + Receipt, Journal and Contra. Of the two keys that can produce `Present`, the + number is absent from the draft and the `REMOTEID` is absent from the read + (below). Wiring a screen to a function that can only ever return + `PossiblyPresent` would misrepresent the capability. The crate is shared, and + the desktop consumes the same function once a draft row carries a number and + a party. +- **`RemoteId` is contract-complete and not reachable from the shipped read.** + `render_agent_vouchers` does not `FETCH` `REMOTEID`; only the AlterID change + feed does. Adding it changes a qualified read profile and needs its own live + evidence, so it is not done here. Until then the report states + `remote_id_observed: false`, so an absence of remote-id matches can never be + read as evidence that none exist. Both motivating engagements were hand-keyed + and would not have had one regardless. +- The window is read in full before any comparison; `vouchers`' own pagination + bounds output, not Tally's work. A window past `MAX_WINDOW_VOUCHERS` is + refused with a narrow-the-range error rather than silently truncated. +- Presence is pure computation over already-observed data, so P1's live-evidence + requirement is satisfied upstream by the two reads that produce its input. Its + own tests are fabricated from a placeholder alphabet: they establish the + behaviour of the rules, and are not, and may not be presented as, evidence + about any Tally instance. **No verdict from this contract has yet been checked + against a real book.** + +## Alternatives rejected + +- **A boolean `already_present`.** Every key available collides or is absent in + the cases that actually blocked. A boolean forces the collision to be + resolved by the code, silently, in whichever direction the author guessed. + The third status is the whole design. +- **Auto-resolving a sole candidate.** A single date-party-amount candidate is + the *most* seductive wrong answer, because it looks decisive. Uniqueness of a + resemblance is not identity; ADR 0016 rejected the same move for the same + reason. +- **Scoring candidates and thresholding.** A score invites a threshold, a + threshold auto-resolves, and here auto-resolution silently deletes an invoice + from a filed GST period. +- **Defaulting an undeclared numbering method to `Manual`.** It would make the + common case work and the automatic-numbering case fail invisibly, matching on + a number Tally discarded — §9.8's trap, re-implemented. +- **Defaulting it to `Unknown`.** Safe, but it means a caller who simply forgot + loses the only key that decides and is never told. Explicit ignorance is + cheap; implicit ignorance is not. +- **Fuzzy party matching inside this module.** It would fork the answer ADR + 0016 owns, and it was directly disproven on the case that mattered: the three + closest names were three different wrong people. +- **Deriving the numbering method from the window** (for example, inferring + `Automatic` from dense consecutive numbering). It is an inference about a + configuration, presented as an observation, and it decides whether the + strongest key is trusted. Exactly the shape of value this project has already + been burned by. diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 51716d05..166a454c 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -137,6 +137,10 @@ "path": "src-tauri/crates/bridge-tally-core/src/bills_reconciliation.rs", "sha256": "c3b0bfa66147b3e90800f0ccf3ede8425b6324c5025af4c0f033b123eb9fe63a" }, + { + "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", + "sha256": "03a3ac71c87294c936076be03fa0dc31136ff914bc97e468a3fcc454e0ed91e1" + }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", "sha256": "58674602eb3131c101ace7638d43cf550d74b29eb3848b0c908687bb2c9fbf9c" @@ -333,6 +337,10 @@ "path": "src-tauri/src/agent_import.rs", "sha256": "606874ff57fc21b8d94c21b3dfe86f515b476a8b3508615b2d18fea1dc4bb814" }, + { + "path": "src-tauri/src/agent_presence.rs", + "sha256": "b2315878f89e44baa33d02fc735e652bf232cca73e706a6d636ebc14e5a4800d" + }, { "path": "src-tauri/src/agent_read_profiles.rs", "sha256": "651de79173793b020a3e350f180604b64a8e1946710164f2a3fe31ec6acbb217" diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs new file mode 100644 index 00000000..15990a43 --- /dev/null +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -0,0 +1,1368 @@ +//! Deterministic answer to "which of these proposed vouchers are already in +//! this company's book?" +//! +//! See `docs/adr/0017-voucher-presence-authority.md`. Tally has no idempotency +//! (`TALLY_PROTOCOL_REFERENCE.md` §9.3): re-sending a voucher creates a second +//! one, so this question stands between a generated batch and an import. +//! +//! Four rules carry the contract. Only an identity key — a `REMOTEID`, or a +//! voucher number on a voucher type declared `Manual` — can produce `Present`. +//! Nothing binds unless it is unique on both sides. `Absent` is only available +//! from a window proven complete and proven to cover the proposal. Everything +//! else is `PossiblyPresent`, which authorises nothing, carries no preferred +//! answer, and is handed to a person. +//! +//! Party matching is not reimplemented here: it is `master_binding`, whose +//! contract already owns "is this the same customer". +//! +//! This module performs no I/O, holds no company identity, and calls no model. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use crate::exact_arithmetic::ExactDecimalAccumulator; +use crate::master_binding::{ + self, comparison_key, BindingStatus, MasterBindingError, MasterCatalog, MasterClass, + SourceEntity, UnboundReason, +}; +use crate::{ExactDecimal, TallyDate}; + +/// Most vouchers one observed window may carry. A window past this is refused +/// with a narrow-the-range error rather than silently compared in part. +pub const MAX_WINDOW_VOUCHERS: usize = 20_000; +/// Most vouchers one proposal set may carry. +pub const MAX_PROPOSED_VOUCHERS: usize = 5_000; +/// Most ledger entries one voucher may carry. +pub const MAX_ENTRIES_PER_VOUCHER: usize = 2_000; +/// Most candidates retained per undecided proposal. +pub const MAX_CANDIDATES_PER_PROPOSAL: usize = 25; +/// Most duplicate-number groups listed in the book observations. +pub const MAX_DUPLICATE_NUMBER_GROUPS: usize = 100; +/// Most book keys listed inside one duplicate-number group. +pub const MAX_KEYS_PER_DUPLICATE_GROUP: usize = 25; +/// Most unbalanced book vouchers listed in the book observations. +pub const MAX_UNBALANCED_LISTED: usize = 100; +/// Longest accepted text field, in characters. This bounds pathological input; +/// it is not a claim about what Tally accepts. +pub const MAX_TEXT_CHARS: usize = 16_384; + +/// Presence refuses rather than degrades. Every variant is a boundary check on +/// input that was never observed, never complete, or already undecidable +/// before any comparison ran. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum PresenceError { + /// The window came from a read that was not complete. A window too dense + /// to read, or one whose emptiness was only partly corroborated, is not + /// "no match found" — and this is the confusion most likely to turn into a + /// duplicated invoice, so it is a type error rather than a flag. + #[error("book window was not read completely")] + WindowIncomplete, + #[error("book window range was invalid")] + WindowRangeInvalid, + #[error("book window exceeded its bound")] + WindowTooLarge, + #[error("book window carried a voucher dated outside its own range")] + WindowVoucherOutsideRange, + #[error("book window carried the same voucher key twice")] + WindowDuplicateVoucherKey, + /// A proposal dated outside the window would be judged against evidence + /// that could not contain it. + #[error("book window does not cover every proposed date")] + WindowDoesNotCover, + #[error("no vouchers were proposed")] + ProposalsEmpty, + #[error("proposed voucher list exceeded its bound")] + TooManyProposals, + #[error("voucher entry list exceeded its bound")] + TooManyEntries, + /// A voucher type whose numbering method nobody stated. Defaulting it + /// would silently decide whether the only decisive key is usable. + #[error("a proposed voucher type has no declared numbering method")] + NumberingMethodUndeclared, + #[error("a voucher type was declared twice with different numbering")] + NumberingMethodConflict, + #[error("text field was blank")] + TextBlank, + #[error("text field exceeded its bound")] + TextTooLong, + #[error("text field carried a control character")] + TextUnsafe, + #[error("date was not a valid Tally date")] + DateInvalid, + #[error("amount was not an exact decimal")] + AmountInvalid, + /// Presence compares party names against ledgers. + #[error("master catalog was not a ledger catalog")] + CatalogClassInvalid, + #[error("party binding refused the input")] + PartyBinding(MasterBindingError), +} + +impl PresenceError { + /// A stable code safe to surface to an operator or a tool result. + pub fn safe_reason_code(&self) -> &'static str { + match self { + Self::WindowIncomplete => "presence_window_incomplete", + Self::WindowRangeInvalid => "presence_window_range_invalid", + Self::WindowTooLarge => "presence_window_too_large", + Self::WindowVoucherOutsideRange => "presence_window_voucher_outside_range", + Self::WindowDuplicateVoucherKey => "presence_window_duplicate_voucher_key", + Self::WindowDoesNotCover => "presence_window_does_not_cover", + Self::ProposalsEmpty => "presence_proposals_empty", + Self::TooManyProposals => "presence_proposals_too_many", + Self::TooManyEntries => "presence_entries_too_many", + Self::NumberingMethodUndeclared => "presence_numbering_method_undeclared", + Self::NumberingMethodConflict => "presence_numbering_method_conflict", + Self::TextBlank => "presence_text_blank", + Self::TextTooLong => "presence_text_too_long", + Self::TextUnsafe => "presence_text_unsafe", + Self::DateInvalid => "presence_date_invalid", + Self::AmountInvalid => "presence_amount_invalid", + Self::CatalogClassInvalid => "presence_catalog_class_invalid", + Self::PartyBinding(error) => error.safe_reason_code(), + } + } +} + +/// How completely the window's source read observed its range. Only a complete +/// read may become a `BookWindow`; the other value exists so a caller must +/// state which it has rather than omit the question. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WindowRead { + Complete, + Partial, +} + +/// A voucher type's numbering method decides whether its voucher number is an +/// identity or a coincidence (§9.8). Under `Automatic`, Tally discards the +/// supplied number, so a number-based key is silently ineffective. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum NumberingMethod { + Manual, + Automatic, + /// Nobody has observed it. Legal, honest, and the common case; it demotes + /// the number from identity to resemblance. + Unknown, +} + +/// Whether an observed voucher has accounting effect. A cancelled or optional +/// voucher still occupies its number, so it can be matched and must never be +/// reported as a posted duplicate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PostingState { + Posted, + Cancelled, + Optional, +} + +/// One ledger entry, from either side of the comparison. The same function +/// derives a magnitude from both, so the two sides cannot compute one fact +/// differently. +#[derive(Debug, Clone, Copy)] +pub struct ObservedEntry<'a> { + pub ledger: &'a str, + pub amount: &'a str, +} + +/// One voucher as the book was observed to hold it. +#[derive(Debug, Clone, Copy)] +pub struct ObservedVoucher<'a> { + /// An opaque caller-owned key for this voucher. It is echoed back in the + /// report and never interpreted, so a caller chooses whatever it can join + /// on without granting this crate any identity. + pub key: &'a str, + pub date: &'a str, + pub voucher_type: &'a str, + pub voucher_number: Option<&'a str>, + pub remote_id: Option<&'a str>, + /// `PARTYLEDGERNAME`, when the read carried one. + pub party: Option<&'a str>, + pub entries: &'a [ObservedEntry<'a>], + pub cancelled: bool, + pub optional: bool, +} + +/// One voucher a source document proposes to import. +#[derive(Debug, Clone, Copy)] +pub struct ProposedVoucherInput<'a> { + pub position: usize, + pub date: &'a str, + pub voucher_type: &'a str, + pub voucher_number: Option<&'a str>, + pub remote_id: Option<&'a str>, + /// The party name exactly as the source document gives it. It is bound + /// through `master_binding`, never compared raw. + pub party: Option<&'a str>, + pub entries: &'a [ObservedEntry<'a>], +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BookVoucher { + key: String, + date: TallyDate, + voucher_type: String, + voucher_number: Option, + remote_id: Option, + party: Option, + ledger_keys: BTreeSet, + magnitude: ExactDecimal, + balanced: bool, + posting: PostingState, + type_key: String, + number_key: Option, +} + +impl BookVoucher { + pub fn observed(input: ObservedVoucher<'_>) -> Result { + let key = validated_text(input.key)?; + let date = + TallyDate::parse(input.date.to_string()).map_err(|_| PresenceError::DateInvalid)?; + let voucher_type = validated_text(input.voucher_type)?; + let voucher_number = input.voucher_number.map(validated_text).transpose()?; + let remote_id = input.remote_id.map(validated_text).transpose()?; + let party = input.party.map(validated_text).transpose()?; + let (magnitude, balanced, mut ledger_keys) = magnitude_of(input.entries)?; + if let Some(party) = party.as_deref() { + ledger_keys.insert(comparison_key(party)); + } + let type_key = comparison_key(&voucher_type); + let number_key = voucher_number.as_deref().map(comparison_key); + Ok(Self { + key, + date, + voucher_type, + voucher_number, + remote_id, + party, + ledger_keys, + magnitude, + balanced, + // A cancelled voucher is cancelled whatever else it is. + posting: match (input.cancelled, input.optional) { + (true, _) => PostingState::Cancelled, + (false, true) => PostingState::Optional, + (false, false) => PostingState::Posted, + }, + type_key, + number_key, + }) + } + + pub fn key(&self) -> &str { + &self.key + } + + pub fn date(&self) -> &str { + self.date.as_str() + } + + pub fn voucher_type(&self) -> &str { + &self.voucher_type + } + + pub fn voucher_number(&self) -> Option<&str> { + self.voucher_number.as_deref() + } + + pub fn party(&self) -> Option<&str> { + self.party.as_deref() + } + + pub fn magnitude(&self) -> &ExactDecimal { + &self.magnitude + } + + pub fn posting(&self) -> PostingState { + self.posting + } + + /// Whether the observed entries summed to zero. An unbalanced voucher is + /// reported and still participates in every rule: excluding it would make + /// `Absent` more likely, which is the wrong direction. + pub fn balanced(&self) -> bool { + self.balanced + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProposedVoucher { + position: usize, + date: TallyDate, + voucher_type: String, + voucher_number: Option, + remote_id: Option, + party: Option, + magnitude: ExactDecimal, + balanced: bool, + type_key: String, + number_key: Option, +} + +impl ProposedVoucher { + pub fn new(input: ProposedVoucherInput<'_>) -> Result { + let date = + TallyDate::parse(input.date.to_string()).map_err(|_| PresenceError::DateInvalid)?; + let voucher_type = validated_text(input.voucher_type)?; + let voucher_number = input.voucher_number.map(validated_text).transpose()?; + let remote_id = input.remote_id.map(validated_text).transpose()?; + let party = input.party.map(validated_text).transpose()?; + let (magnitude, balanced, _) = magnitude_of(input.entries)?; + let type_key = comparison_key(&voucher_type); + let number_key = voucher_number.as_deref().map(comparison_key); + Ok(Self { + position: input.position, + date, + voucher_type, + voucher_number, + remote_id, + party, + magnitude, + balanced, + type_key, + number_key, + }) + } + + pub fn position(&self) -> usize { + self.position + } + + pub fn date(&self) -> &str { + self.date.as_str() + } + + pub fn voucher_type(&self) -> &str { + &self.voucher_type + } + + pub fn magnitude(&self) -> &ExactDecimal { + &self.magnitude + } + + pub fn balanced(&self) -> bool { + self.balanced + } +} + +/// One observed window of a company's book. It can only be constructed from a +/// read that observed its whole range, so "the window was too dense to read" +/// can never reach a comparison as "nothing matched". +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BookWindow { + from: TallyDate, + to: TallyDate, + vouchers: Vec, +} + +impl BookWindow { + pub fn observed( + from: &str, + to: &str, + read: WindowRead, + vouchers: Vec, + ) -> Result { + if read != WindowRead::Complete { + return Err(PresenceError::WindowIncomplete); + } + let from = TallyDate::parse(from.to_string()).map_err(|_| PresenceError::DateInvalid)?; + let to = TallyDate::parse(to.to_string()).map_err(|_| PresenceError::DateInvalid)?; + if from.as_str() > to.as_str() { + return Err(PresenceError::WindowRangeInvalid); + } + if vouchers.len() > MAX_WINDOW_VOUCHERS { + return Err(PresenceError::WindowTooLarge); + } + let mut keys = BTreeSet::new(); + for voucher in &vouchers { + if voucher.date() < from.as_str() || voucher.date() > to.as_str() { + return Err(PresenceError::WindowVoucherOutsideRange); + } + if !keys.insert(voucher.key()) { + return Err(PresenceError::WindowDuplicateVoucherKey); + } + } + Ok(Self { from, to, vouchers }) + } + + pub fn from(&self) -> &str { + self.from.as_str() + } + + pub fn to(&self) -> &str { + self.to.as_str() + } + + pub fn vouchers(&self) -> &[BookVoucher] { + &self.vouchers + } + + fn covers(&self, date: &str) -> bool { + date >= self.from.as_str() && date <= self.to.as_str() + } +} + +/// The numbering method of every voucher type a proposal names. A type that is +/// missing is an error, not a default: the declaration decides whether the only +/// decisive key is usable at all. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct NumberingDeclaration { + methods: BTreeMap, +} + +impl NumberingDeclaration { + pub fn new(entries: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let mut methods = BTreeMap::new(); + for (voucher_type, method) in entries { + let key = comparison_key(&validated_text(voucher_type.as_ref())?); + if methods + .insert(key, method) + .is_some_and(|prior| prior != method) + { + return Err(PresenceError::NumberingMethodConflict); + } + } + Ok(Self { methods }) + } + + fn method(&self, type_key: &str) -> Option { + self.methods.get(type_key).copied() + } +} + +/// How a proposal's party name resolved against the observed ledger catalog. +/// It reports the binding and nothing more: an ambiguous party's candidates +/// belong to `validate_masters`, which owns that vocabulary, and naming one of +/// them here would be the auto-resolution ADR 0016 forbids. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case", tag = "party_state")] +pub enum PartyOutcome { + NotSupplied, + Bound { + catalog_name: String, + }, + Ambiguous { + reason: String, + candidate_count: usize, + }, + Unmatched { + reason: String, + }, +} + +/// The evidence that decided a `Present`. Both are identity. Date, amount and +/// party are never a basis; they are the keys that measurably collide. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PresenceBasis { + RemoteId, + ManualVoucherNumber, +} + +/// The rule that surfaced a candidate. Ordered by `rank`, never by similarity, +/// and no candidate is marked best. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CandidateRule { + SharedRemoteId, + SharedVoucherNumber, + SameDatePartyAmount, + SamePartyAmount, + SameDateAmount, + SameDateParty, +} + +impl CandidateRule { + fn rank(self) -> u8 { + match self { + Self::SharedRemoteId => 0, + Self::SharedVoucherNumber => 1, + Self::SameDatePartyAmount => 2, + Self::SamePartyAmount => 3, + Self::SameDateAmount => 4, + Self::SameDateParty => 5, + } + } +} + +/// A book voucher an operator may judge, with the rule that surfaced it. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct PresenceCandidate { + pub book_key: String, + pub rule: CandidateRule, +} + +/// Why a proposal was not decided. Exactly one, by the precedence in +/// `assess`: a collision outranks a resemblance, and a resemblance outranks an +/// incomplete party comparison. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum UndecidedReason { + /// One `REMOTEID` is carried by more than one voucher on either side. + RemoteIdCollision, + /// The number selects more than one book voucher. This is the book that + /// held twenty-five invoices sharing numbers in one month. + BookNumberCollision, + /// More than one proposal claims the number under manual numbering. + ProposalNumberCollision, + /// An identity key landed on a cancelled or optional voucher. It occupies + /// the number but has no accounting effect. + MatchedVoucherNotPosted, + /// A number matched, but the voucher type is not declared `Manual`, so the + /// number is not identity (§9.8). + NumberNotDecisive, + /// A number matched under a voucher type this window never observed. The + /// number was compared across every type rather than manufacture an + /// absence, so it is a resemblance and not a series position. + VoucherTypeNotObserved, + /// Date, party or amount resembles a book voucher. These collide in real + /// data and never decide. + ResemblesBookVoucher, + /// The party comparison could not be completed, so no rule that needs a + /// party actually ran and `Absent` is not available. + PartyNotDecidable, +} + +impl UndecidedReason { + /// A stable code safe to surface to an operator or a tool result. + pub fn safe_reason_code(self) -> &'static str { + match self { + Self::RemoteIdCollision => "presence_remote_id_collision", + Self::BookNumberCollision => "presence_book_number_collision", + Self::ProposalNumberCollision => "presence_proposal_number_collision", + Self::MatchedVoucherNotPosted => "presence_matched_voucher_not_posted", + Self::NumberNotDecisive => "presence_number_not_decisive", + Self::VoucherTypeNotObserved => "presence_voucher_type_not_observed", + Self::ResemblesBookVoucher => "presence_resembles_book_voucher", + Self::PartyNotDecidable => "presence_party_not_decidable", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DifferenceField { + Date, + Amount, + Party, +} + +/// A field on which an identified voucher disagrees with its source. The match +/// was decided by identity, so a difference is a finding about the book — not +/// evidence against the match. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Difference { + pub field: DifferenceField, + pub proposed: Option, + pub observed: Option, +} + +/// What could not be decided, and why. This is the operator's work item, not +/// an error path — and it carries no field that names a match. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Undecided { + pub reason: UndecidedReason, + pub candidates: Vec, + /// Candidates found before truncation. + pub candidate_count: usize, + pub candidates_truncated: bool, +} + +/// Exactly one outcome per proposed voucher. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case", tag = "presence")] +pub enum PresenceStatus { + /// An identity key matched uniquely on both sides. This is the only status + /// that names a book voucher, and the only one that authorises excluding a + /// voucher from an import. + Present { + book_key: String, + basis: PresenceBasis, + differences: Vec, + }, + /// Something resembles it, or something prevented a decision. Authorises + /// nothing. + PossiblyPresent(Undecided), + /// No rule produced any candidate, in a window proven to cover it. + /// `Absent` is always relative to that window. + Absent, +} + +/// One proposed voucher and its outcome. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct VoucherPresence { + pub position: usize, + /// The voucher number exactly as the source document gave it. + pub voucher_number: Option, + pub numbering_method: NumberingMethod, + /// Whether this proposal's voucher type was observed anywhere in the + /// window. When it was not, type stops discriminating and number matching + /// widens to every observed type — narrowing on an unobserved type name + /// would manufacture absence. + pub voucher_type_observed: bool, + pub party: PartyOutcome, + #[serde(flatten)] + pub status: PresenceStatus, +} + +impl VoucherPresence { + pub fn present_book_key(&self) -> Option<&str> { + match &self.status { + PresenceStatus::Present { book_key, .. } => Some(book_key.as_str()), + _ => None, + } + } + + pub fn undecided(&self) -> Option<&Undecided> { + match &self.status { + PresenceStatus::PossiblyPresent(undecided) => Some(undecided), + _ => None, + } + } + + pub fn is_absent(&self) -> bool { + matches!(self.status, PresenceStatus::Absent) + } +} + +/// A (voucher type, number) pair that identifies more than one book voucher. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct DuplicateNumberGroup { + pub voucher_type: String, + pub voucher_number: String, + pub book_keys: Vec, + pub book_voucher_count: usize, +} + +/// What the book gave away while it was being indexed. These cost nothing to +/// compute and one of them is a filed-return problem. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct BookObservations { + pub duplicate_numbers: Vec, + pub duplicate_number_group_count: usize, + pub duplicate_numbers_truncated: bool, + pub unbalanced_vouchers: Vec, + pub unbalanced_voucher_count: usize, + /// Vouchers of a proposed voucher type that no proposal matched or even + /// resembled — the other half of a reconciliation. Counted, not listed. + pub unmatched_book_vouchers: usize, + pub window_voucher_count: usize, + /// Whether any observed voucher carried a `REMOTEID` at all. Without this, + /// an absence of remote-id matches reads as evidence that none exist. + pub remote_id_observed: bool, +} + +/// Control totals for one run. `requested == present + possibly_present + +/// absent` always holds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub struct PresenceTotals { + pub requested: usize, + pub present: usize, + pub possibly_present: usize, + pub absent: usize, +} + +/// The result of one presence run, scoped to the window it was computed over. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct PresenceReport { + window_from: String, + window_to: String, + vouchers: Vec, + observations: BookObservations, +} + +impl PresenceReport { + /// The window every verdict is relative to. `Absent` means absent from + /// this range, never absent from the book. + pub fn window(&self) -> (&str, &str) { + (&self.window_from, &self.window_to) + } + + pub fn vouchers(&self) -> &[VoucherPresence] { + &self.vouchers + } + + pub fn observations(&self) -> &BookObservations { + &self.observations + } + + /// The vouchers an import may carry. Nothing else is safe to include + /// without a person. + pub fn absent(&self) -> impl Iterator { + self.vouchers.iter().filter(|entry| entry.is_absent()) + } + + pub fn present(&self) -> impl Iterator { + self.vouchers + .iter() + .filter(|entry| entry.present_book_key().is_some()) + } + + pub fn possibly_present(&self) -> impl Iterator { + self.vouchers + .iter() + .filter(|entry| entry.undecided().is_some()) + } + + pub fn totals(&self) -> PresenceTotals { + let present = self.present().count(); + let possibly_present = self.possibly_present().count(); + let absent = self.absent().count(); + PresenceTotals { + requested: self.vouchers.len(), + present, + possibly_present, + absent, + } + } +} + +/// Already-valid inputs for one presence run. Every cross-input refusal — the +/// window covering the proposals, a declared numbering method for every +/// proposed type, a ledger catalog, the party binding itself — happens here, +/// so `assess` cannot fail and no caller can compensate differently. +#[derive(Debug)] +pub struct PresenceRequest<'a> { + window: &'a BookWindow, + numbering: &'a NumberingDeclaration, + proposals: &'a [ProposedVoucher], + party_bindings: Vec, +} + +/// A bound party reduced to what the rules need: the names to compare against, +/// and whether the comparison was complete enough to justify `Absent`. +#[derive(Debug, Clone, PartialEq, Eq)] +struct PartyResolution { + outcome: PartyOutcome, + compare_keys: BTreeSet, + /// True when names that might have matched were never compared. + incomplete: bool, +} + +impl<'a> PresenceRequest<'a> { + pub fn new( + window: &'a BookWindow, + catalog: &'a MasterCatalog, + numbering: &'a NumberingDeclaration, + proposals: &'a [ProposedVoucher], + ) -> Result { + if catalog.class() != MasterClass::Ledger { + return Err(PresenceError::CatalogClassInvalid); + } + if proposals.is_empty() { + return Err(PresenceError::ProposalsEmpty); + } + if proposals.len() > MAX_PROPOSED_VOUCHERS { + return Err(PresenceError::TooManyProposals); + } + for proposal in proposals { + if !window.covers(proposal.date()) { + return Err(PresenceError::WindowDoesNotCover); + } + if numbering.method(&proposal.type_key).is_none() { + return Err(PresenceError::NumberingMethodUndeclared); + } + } + let party_bindings = bind_parties(catalog, proposals)?; + Ok(Self { + window, + numbering, + proposals, + party_bindings, + }) + } +} + +/// Binds every distinct proposed party name through `master_binding`, once, and +/// reduces each to the names the rules may compare against. +fn bind_parties( + catalog: &MasterCatalog, + proposals: &[ProposedVoucher], +) -> Result, PresenceError> { + let mut distinct: Vec<&str> = proposals + .iter() + .filter_map(|proposal| proposal.party.as_deref()) + .collect(); + distinct.sort_unstable(); + distinct.dedup(); + let entities = distinct + .iter() + .enumerate() + .map(|(position, name)| SourceEntity::new(position, name)) + .collect::, _>>() + .map_err(PresenceError::PartyBinding)?; + let report = master_binding::bind(catalog, &entities).map_err(PresenceError::PartyBinding)?; + let resolved = distinct + .iter() + .zip(report.entities()) + .map(|(name, binding)| ((*name).to_string(), resolution_of(binding))) + .collect::>(); + Ok(proposals + .iter() + .map(|proposal| match proposal.party.as_deref() { + None => PartyResolution { + outcome: PartyOutcome::NotSupplied, + compare_keys: BTreeSet::new(), + incomplete: false, + }, + Some(name) => resolved + .get(name) + .cloned() + .expect("every proposed party name was bound"), + }) + .collect()) +} + +fn resolution_of(binding: &master_binding::EntityBinding) -> PartyResolution { + match &binding.status { + BindingStatus::Bound { catalog_name, .. } => PartyResolution { + outcome: PartyOutcome::Bound { + catalog_name: catalog_name.clone(), + }, + compare_keys: BTreeSet::from([comparison_key(catalog_name)]), + incomplete: false, + }, + // Every candidate is compared, never one of them. Widening the net can + // only produce more resemblance, which is the safe direction here. + BindingStatus::Ambiguous(unresolved) => PartyResolution { + outcome: PartyOutcome::Ambiguous { + reason: unresolved.reason.safe_reason_code().to_string(), + candidate_count: unresolved.candidate_count, + }, + compare_keys: unresolved + .candidates + .iter() + .map(|candidate| comparison_key(&candidate.catalog_name)) + .collect(), + // A name family is deliberately not listed, and a truncated list + // leaves names uncompared. Either way `Absent` would rest on a + // comparison that never ran. + incomplete: unresolved.reason == UnboundReason::NoDiscriminatingCandidate + || unresolved.candidates_truncated, + }, + // Nothing in this book resembles the party, so no posted voucher can + // be carrying it. Party rules simply do not run. + BindingStatus::Unmatched(unresolved) => PartyResolution { + outcome: PartyOutcome::Unmatched { + reason: unresolved.reason.safe_reason_code().to_string(), + }, + compare_keys: BTreeSet::new(), + incomplete: unresolved.reason == UnboundReason::NoDiscriminatingCandidate + || unresolved.candidates_truncated, + }, + } +} + +/// Indexes of one window, built once per run. +struct WindowIndex<'a> { + by_remote_id: BTreeMap<&'a str, Vec>, + by_type_and_number: BTreeMap<(&'a str, &'a str), Vec>, + by_number: BTreeMap<&'a str, Vec>, + by_date: BTreeMap<&'a str, Vec>, + by_ledger: BTreeMap<&'a str, Vec>, + type_keys: BTreeSet<&'a str>, +} + +impl<'a> WindowIndex<'a> { + fn build(window: &'a BookWindow) -> Self { + let mut index = Self { + by_remote_id: BTreeMap::new(), + by_type_and_number: BTreeMap::new(), + by_number: BTreeMap::new(), + by_date: BTreeMap::new(), + by_ledger: BTreeMap::new(), + type_keys: BTreeSet::new(), + }; + for (position, voucher) in window.vouchers.iter().enumerate() { + index.type_keys.insert(voucher.type_key.as_str()); + if let Some(remote_id) = voucher.remote_id.as_deref() { + index + .by_remote_id + .entry(remote_id) + .or_default() + .push(position); + } + if let Some(number_key) = voucher.number_key.as_deref() { + index + .by_type_and_number + .entry((voucher.type_key.as_str(), number_key)) + .or_default() + .push(position); + index + .by_number + .entry(number_key) + .or_default() + .push(position); + } + index + .by_date + .entry(voucher.date.as_str()) + .or_default() + .push(position); + for ledger in &voucher.ledger_keys { + index + .by_ledger + .entry(ledger.as_str()) + .or_default() + .push(position); + } + } + index + } +} + +/// Decides every proposal against the window. +/// +/// `Present` requires identity unique on both sides. `Absent` requires that no +/// rule produced any candidate. Everything between is `PossiblyPresent` and is +/// never resolved here. See `docs/adr/0017-voucher-presence-authority.md` for +/// why the two bars are set at different heights. +pub fn assess(request: &PresenceRequest<'_>) -> PresenceReport { + let window = request.window; + let index = WindowIndex::build(window); + + let mut proposal_remote_counts: BTreeMap<&str, usize> = BTreeMap::new(); + let mut proposal_number_counts: BTreeMap<(&str, &str), usize> = BTreeMap::new(); + for proposal in request.proposals { + if let Some(remote_id) = proposal.remote_id.as_deref() { + *proposal_remote_counts.entry(remote_id).or_default() += 1; + } + if let Some(number_key) = proposal.number_key.as_deref() { + *proposal_number_counts + .entry((proposal.type_key.as_str(), number_key)) + .or_default() += 1; + } + } + + let mut touched_book: BTreeSet = BTreeSet::new(); + let mut proposed_type_keys: BTreeSet<&str> = BTreeSet::new(); + let mut vouchers = Vec::with_capacity(request.proposals.len()); + for (proposal, party) in request.proposals.iter().zip(&request.party_bindings) { + proposed_type_keys.insert(proposal.type_key.as_str()); + let decided = decide( + proposal, + party, + window, + &index, + request.numbering, + &proposal_remote_counts, + &proposal_number_counts, + ); + match &decided.status { + PresenceStatus::Present { book_key, .. } => { + if let Some(position) = window + .vouchers + .iter() + .position(|voucher| voucher.key() == book_key) + { + touched_book.insert(position); + } + } + PresenceStatus::PossiblyPresent(undecided) => { + for candidate in &undecided.candidates { + if let Some(position) = window + .vouchers + .iter() + .position(|voucher| voucher.key() == candidate.book_key) + { + touched_book.insert(position); + } + } + } + PresenceStatus::Absent => {} + } + vouchers.push(decided); + } + + let observations = observe(window, &index, &proposed_type_keys, &touched_book); + PresenceReport { + window_from: window.from().to_string(), + window_to: window.to().to_string(), + vouchers, + observations, + } +} + +#[allow(clippy::too_many_arguments)] +fn decide( + proposal: &ProposedVoucher, + party: &PartyResolution, + window: &BookWindow, + index: &WindowIndex<'_>, + numbering: &NumberingDeclaration, + proposal_remote_counts: &BTreeMap<&str, usize>, + proposal_number_counts: &BTreeMap<(&str, &str), usize>, +) -> VoucherPresence { + let method = numbering + .method(&proposal.type_key) + .expect("PresenceRequest refused an undeclared numbering method"); + let type_observed = index.type_keys.contains(proposal.type_key.as_str()); + let shell = |status: PresenceStatus| VoucherPresence { + position: proposal.position, + voucher_number: proposal.voucher_number.clone(), + numbering_method: method, + voucher_type_observed: type_observed, + party: party.outcome.clone(), + status, + }; + + // Rule one: identity first. A REMOTEID is a key Bridge itself wrote. + if let Some(remote_id) = proposal.remote_id.as_deref() { + if let Some(matches) = index.by_remote_id.get(remote_id) { + let unique_here = proposal_remote_counts.get(remote_id).copied() == Some(1); + if matches.len() == 1 && unique_here { + return shell(settled( + proposal, + party, + &window.vouchers[matches[0]], + PresenceBasis::RemoteId, + )); + } + return shell(PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::RemoteIdCollision, + candidates_from(window, matches, CandidateRule::SharedRemoteId), + ))); + } + } + + // Rule two: a voucher number is identity only where the numbering method + // preserves it (§9.8), and only when it is unique on both sides. + let number_matches: Vec = proposal + .number_key + .as_deref() + .map(|number_key| { + if type_observed { + index + .by_type_and_number + .get(&(proposal.type_key.as_str(), number_key)) + .cloned() + .unwrap_or_default() + } else { + // The type name was never observed, so it discriminates + // nothing. Widen rather than manufacture an absence. + index.by_number.get(number_key).cloned().unwrap_or_default() + } + }) + .unwrap_or_default(); + + // Manual numbering only decides within an observed voucher type: numbers + // are a per-type series, so a cross-type number match is a resemblance. + if method == NumberingMethod::Manual && type_observed { + if let Some(number_key) = proposal.number_key.as_deref() { + let proposed_twice = proposal_number_counts + .get(&(proposal.type_key.as_str(), number_key)) + .copied() + .unwrap_or_default() + > 1; + if proposed_twice { + return shell(PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::ProposalNumberCollision, + candidates_from(window, &number_matches, CandidateRule::SharedVoucherNumber), + ))); + } + if number_matches.len() > 1 { + return shell(PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::BookNumberCollision, + candidates_from(window, &number_matches, CandidateRule::SharedVoucherNumber), + ))); + } + if number_matches.len() == 1 { + return shell(settled( + proposal, + party, + &window.vouchers[number_matches[0]], + PresenceBasis::ManualVoucherNumber, + )); + } + } + } + + // Rule three: everything else is resemblance, and resemblance decides + // nothing. It only widens what a person is asked to look at. + let mut found: BTreeMap = BTreeMap::new(); + for position in &number_matches { + keep_strongest(&mut found, *position, CandidateRule::SharedVoucherNumber); + } + let mut pool: BTreeSet = BTreeSet::new(); + if let Some(positions) = index.by_date.get(proposal.date()) { + pool.extend(positions.iter().copied()); + } + for key in &party.compare_keys { + if let Some(positions) = index.by_ledger.get(key.as_str()) { + pool.extend(positions.iter().copied()); + } + } + for position in pool { + let voucher = &window.vouchers[position]; + let same_date = voucher.date() == proposal.date(); + let same_amount = voucher.magnitude.numeric_eq(&proposal.magnitude); + let same_party = party + .compare_keys + .iter() + .any(|key| voucher.ledger_keys.contains(key)); + let rule = match (same_date, same_party, same_amount) { + (true, true, true) => CandidateRule::SameDatePartyAmount, + (_, true, true) => CandidateRule::SamePartyAmount, + (true, false, true) => CandidateRule::SameDateAmount, + (true, true, false) => CandidateRule::SameDateParty, + _ => continue, + }; + keep_strongest(&mut found, position, rule); + } + + if found.is_empty() { + // Nothing resembled it — but if the party comparison never ran to + // completion, that absence is not evidence. + if party.incomplete { + return shell(PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::PartyNotDecidable, + Vec::new(), + ))); + } + return shell(PresenceStatus::Absent); + } + + let reason = match ( + number_matches.is_empty(), + type_observed, + method == NumberingMethod::Manual, + ) { + (false, false, _) => UndecidedReason::VoucherTypeNotObserved, + (false, true, false) => UndecidedReason::NumberNotDecisive, + _ => UndecidedReason::ResemblesBookVoucher, + }; + let mut candidates = found + .into_iter() + .map(|(position, rule)| PresenceCandidate { + book_key: window.vouchers[position].key().to_string(), + rule, + }) + .collect::>(); + candidates.sort_by(|left, right| { + left.rule + .rank() + .cmp(&right.rule.rank()) + .then_with(|| left.book_key.cmp(&right.book_key)) + }); + shell(PresenceStatus::PossiblyPresent(undecided( + reason, candidates, + ))) +} + +/// Turns an identity match into a status. A cancelled or optional voucher +/// occupies the number without being posted, so it is never `Present`. +fn settled( + proposal: &ProposedVoucher, + party: &PartyResolution, + voucher: &BookVoucher, + basis: PresenceBasis, +) -> PresenceStatus { + if voucher.posting != PostingState::Posted { + return PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::MatchedVoucherNotPosted, + vec![PresenceCandidate { + book_key: voucher.key().to_string(), + rule: match basis { + PresenceBasis::RemoteId => CandidateRule::SharedRemoteId, + PresenceBasis::ManualVoucherNumber => CandidateRule::SharedVoucherNumber, + }, + }], + )); + } + PresenceStatus::Present { + book_key: voucher.key().to_string(), + basis, + differences: differences(proposal, party, voucher), + } +} + +/// What an identified voucher disagrees with its source about. One engagement +/// found an invoice posted short by exactly one dropped GST head this way. +fn differences( + proposal: &ProposedVoucher, + party: &PartyResolution, + voucher: &BookVoucher, +) -> Vec { + let mut differences = Vec::new(); + if voucher.date() != proposal.date() { + differences.push(Difference { + field: DifferenceField::Date, + proposed: Some(proposal.date().to_string()), + observed: Some(voucher.date().to_string()), + }); + } + if !voucher.magnitude.numeric_eq(&proposal.magnitude) { + differences.push(Difference { + field: DifferenceField::Amount, + proposed: Some(proposal.magnitude.as_str().to_string()), + observed: Some(voucher.magnitude.as_str().to_string()), + }); + } + // Only a bound party can disagree. An ambiguous one has no single name to + // disagree with, and asserting a difference from a candidate would be the + // same guess by another route. + if let PartyOutcome::Bound { catalog_name } = &party.outcome { + if !voucher.ledger_keys.contains(&comparison_key(catalog_name)) { + differences.push(Difference { + field: DifferenceField::Party, + proposed: Some(catalog_name.clone()), + observed: voucher.party.clone(), + }); + } + } + differences +} + +fn observe( + window: &BookWindow, + index: &WindowIndex<'_>, + proposed_type_keys: &BTreeSet<&str>, + touched: &BTreeSet, +) -> BookObservations { + let mut duplicate_numbers = Vec::new(); + let mut duplicate_number_group_count = 0_usize; + for ((_, _), positions) in &index.by_type_and_number { + if positions.len() < 2 { + continue; + } + duplicate_number_group_count += 1; + if duplicate_numbers.len() >= MAX_DUPLICATE_NUMBER_GROUPS { + continue; + } + let first = &window.vouchers[positions[0]]; + duplicate_numbers.push(DuplicateNumberGroup { + voucher_type: first.voucher_type.clone(), + voucher_number: first.voucher_number.clone().unwrap_or_default(), + book_keys: positions + .iter() + .take(MAX_KEYS_PER_DUPLICATE_GROUP) + .map(|position| window.vouchers[*position].key().to_string()) + .collect(), + book_voucher_count: positions.len(), + }); + } + + let unbalanced: Vec<&BookVoucher> = window + .vouchers + .iter() + .filter(|voucher| !voucher.balanced()) + .collect(); + + let unmatched_book_vouchers = window + .vouchers + .iter() + .enumerate() + .filter(|(position, voucher)| { + proposed_type_keys.contains(voucher.type_key.as_str()) && !touched.contains(position) + }) + .count(); + + BookObservations { + duplicate_numbers, + duplicate_number_group_count, + duplicate_numbers_truncated: duplicate_number_group_count > MAX_DUPLICATE_NUMBER_GROUPS, + unbalanced_vouchers: unbalanced + .iter() + .take(MAX_UNBALANCED_LISTED) + .map(|voucher| voucher.key().to_string()) + .collect(), + unbalanced_voucher_count: unbalanced.len(), + unmatched_book_vouchers, + window_voucher_count: window.vouchers.len(), + remote_id_observed: !index.by_remote_id.is_empty(), + } +} + +fn keep_strongest( + found: &mut BTreeMap, + position: usize, + rule: CandidateRule, +) { + found + .entry(position) + .and_modify(|held| { + if rule.rank() < held.rank() { + *held = rule; + } + }) + .or_insert(rule); +} + +fn candidates_from( + window: &BookWindow, + positions: &[usize], + rule: CandidateRule, +) -> Vec { + positions + .iter() + .map(|position| PresenceCandidate { + book_key: window.vouchers[*position].key().to_string(), + rule, + }) + .collect() +} + +fn undecided(reason: UndecidedReason, candidates: Vec) -> Undecided { + let candidate_count = candidates.len(); + let truncated = candidate_count > MAX_CANDIDATES_PER_PROPOSAL; + let mut candidates = candidates; + candidates.truncate(MAX_CANDIDATES_PER_PROPOSAL); + Undecided { + reason, + candidates, + candidate_count, + candidates_truncated: truncated, + } +} + +/// One definition of a voucher's magnitude, used by both sides so the two can +/// never compute it differently. It is the sum of the positive entry amounts, +/// which is defined whether or not the voucher balances. +fn magnitude_of( + entries: &[ObservedEntry<'_>], +) -> Result<(ExactDecimal, bool, BTreeSet), PresenceError> { + if entries.len() > MAX_ENTRIES_PER_VOUCHER { + return Err(PresenceError::TooManyEntries); + } + let mut total = ExactDecimalAccumulator::default(); + let mut positive = ExactDecimalAccumulator::default(); + let mut ledger_keys = BTreeSet::new(); + for entry in entries { + let amount = ExactDecimal::parse(entry.amount.to_string()) + .map_err(|_| PresenceError::AmountInvalid)?; + total.add(amount.as_str()); + if !amount.is_negative() { + positive.add(amount.as_str()); + } + ledger_keys.insert(comparison_key(&validated_text(entry.ledger)?)); + } + let magnitude = ExactDecimal::parse(positive.canonical_string()) + .map_err(|_| PresenceError::AmountInvalid)?; + Ok((magnitude, total.is_zero(), ledger_keys)) +} + +fn validated_text(value: &str) -> Result { + if value.trim().is_empty() { + return Err(PresenceError::TextBlank); + } + if value.chars().count() > MAX_TEXT_CHARS { + return Err(PresenceError::TextTooLong); + } + if value.chars().any(|character| { + character.is_control() || matches!(character, '\u{2028}' | '\u{2029}' | '\u{feff}') + }) { + return Err(PresenceError::TextUnsafe); + } + Ok(value.to_string()) +} + +#[cfg(test)] +#[path = "book_presence_tests.rs"] +mod tests; diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs new file mode 100644 index 00000000..d6950cdb --- /dev/null +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -0,0 +1,1161 @@ +//! Every name, number and amount here is fabricated from a placeholder +//! alphabet. These tests establish the behaviour of the rules; they are not, +//! and may not be presented as, evidence about any Tally instance. + +use super::*; + +const LEDGERS: [&str; 6] = [ + "Alpha Traders", + "Bravo Industries", + "Charlie Minerals", + "Sales Account", + "Output CGST 9%", + "Output SGST 9%", +]; + +fn catalog() -> MasterCatalog { + MasterCatalog::new(MasterClass::Ledger, LEDGERS).expect("catalog") +} + +fn catalog_of(names: &[&str]) -> MasterCatalog { + MasterCatalog::new(MasterClass::Ledger, names).expect("catalog") +} + +fn entries<'a>(rows: &'a [[&'a str; 2]]) -> Vec> { + rows.iter() + .map(|row| ObservedEntry { + ledger: row[0], + amount: row[1], + }) + .collect() +} + +struct BookRow { + key: &'static str, + date: &'static str, + voucher_type: &'static str, + number: Option<&'static str>, + remote_id: Option<&'static str>, + party: Option<&'static str>, + rows: Vec<[&'static str; 2]>, + cancelled: bool, + optional: bool, +} + +impl BookRow { + fn new(key: &'static str, date: &'static str, number: &'static str) -> Self { + Self { + key, + date, + voucher_type: "Sales", + number: Some(number), + remote_id: None, + party: Some("Alpha Traders"), + rows: vec![ + ["Alpha Traders", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ["Output SGST 9%", "900.00"], + ], + cancelled: false, + optional: false, + } + } + + fn party(mut self, party: &'static str) -> Self { + self.rows[0][0] = party; + self.party = Some(party); + self + } + + fn voucher_type(mut self, voucher_type: &'static str) -> Self { + self.voucher_type = voucher_type; + self + } + + fn remote_id(mut self, remote_id: &'static str) -> Self { + self.remote_id = Some(remote_id); + self + } + + fn rows(mut self, rows: Vec<[&'static str; 2]>) -> Self { + self.rows = rows; + self + } + + fn cancelled(mut self) -> Self { + self.cancelled = true; + self + } + + fn optional(mut self) -> Self { + self.optional = true; + self + } + + fn build(&self) -> BookVoucher { + let entries = entries(&self.rows); + BookVoucher::observed(ObservedVoucher { + key: self.key, + date: self.date, + voucher_type: self.voucher_type, + voucher_number: self.number, + remote_id: self.remote_id, + party: self.party, + entries: &entries, + cancelled: self.cancelled, + optional: self.optional, + }) + .expect("observed voucher") + } +} + +struct ProposalRow { + position: usize, + date: &'static str, + voucher_type: &'static str, + number: Option<&'static str>, + remote_id: Option<&'static str>, + party: Option<&'static str>, + rows: Vec<[&'static str; 2]>, +} + +impl ProposalRow { + fn new(position: usize, date: &'static str, number: &'static str) -> Self { + Self { + position, + date, + voucher_type: "Sales", + number: Some(number), + remote_id: None, + party: Some("Alpha Traders"), + rows: vec![ + ["Alpha Traders", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ["Output SGST 9%", "900.00"], + ], + } + } + + fn party(mut self, party: &'static str) -> Self { + self.rows[0][0] = party; + self.party = Some(party); + self + } + + fn voucher_type(mut self, voucher_type: &'static str) -> Self { + self.voucher_type = voucher_type; + self + } + + fn remote_id(mut self, remote_id: &'static str) -> Self { + self.remote_id = Some(remote_id); + self + } + + fn rows(mut self, rows: Vec<[&'static str; 2]>) -> Self { + self.rows = rows; + self + } + + fn build(&self) -> ProposedVoucher { + let entries = entries(&self.rows); + ProposedVoucher::new(ProposedVoucherInput { + position: self.position, + date: self.date, + voucher_type: self.voucher_type, + voucher_number: self.number, + remote_id: self.remote_id, + party: self.party, + entries: &entries, + }) + .expect("proposed voucher") + } +} + +fn window(rows: &[BookRow]) -> BookWindow { + BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + rows.iter().map(BookRow::build).collect(), + ) + .expect("window") +} + +fn numbering(method: NumberingMethod) -> NumberingDeclaration { + NumberingDeclaration::new([("Sales", method)]).expect("numbering") +} + +fn run( + window: &BookWindow, + catalog: &MasterCatalog, + numbering: &NumberingDeclaration, + proposals: &[ProposedVoucher], +) -> PresenceReport { + let request = PresenceRequest::new(window, catalog, numbering, proposals).expect("request"); + assess(&request) +} + +fn only(report: &PresenceReport) -> &VoucherPresence { + assert_eq!(report.vouchers().len(), 1); + &report.vouchers()[0] +} + +fn reason(entry: &VoucherPresence) -> UndecidedReason { + entry.undecided().expect("undecided").reason +} + +// --- the window is a claim about a window ------------------------------ + +#[test] +fn a_partial_read_can_never_become_a_window() { + let error = BookWindow::observed("20260801", "20260831", WindowRead::Partial, Vec::new()) + .expect_err("a partial read is not a window"); + assert_eq!(error, PresenceError::WindowIncomplete); + assert_eq!(error.safe_reason_code(), "presence_window_incomplete"); +} + +#[test] +fn an_empty_complete_window_is_legal_and_reports_everything_absent() { + let window = window(&[]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert!(only(&report).is_absent()); + assert_eq!(report.totals().absent, 1); +} + +#[test] +fn a_window_refuses_a_voucher_dated_outside_its_own_range() { + let outside = BookRow::new("book-1", "20260901", "AA0118").build(); + assert_eq!( + BookWindow::observed("20260801", "20260831", WindowRead::Complete, vec![outside]) + .expect_err("outside"), + PresenceError::WindowVoucherOutsideRange + ); +} + +#[test] +fn a_window_refuses_the_same_voucher_key_twice() { + let rows = vec![ + BookRow::new("book-1", "20260812", "AA0118").build(), + BookRow::new("book-1", "20260813", "AA0119").build(), + ]; + assert_eq!( + BookWindow::observed("20260801", "20260831", WindowRead::Complete, rows) + .expect_err("duplicate"), + PresenceError::WindowDuplicateVoucherKey + ); +} + +#[test] +fn a_window_refuses_an_inverted_range() { + assert_eq!( + BookWindow::observed("20260831", "20260801", WindowRead::Complete, Vec::new()) + .expect_err("inverted"), + PresenceError::WindowRangeInvalid + ); +} + +#[test] +fn a_proposal_outside_the_window_is_refused_rather_than_judged() { + let window = window(&[]); + let proposals = [ProposalRow::new(0, "20260902", "AA0118").build()]; + assert_eq!( + PresenceRequest::new( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals + ) + .expect_err("uncovered"), + PresenceError::WindowDoesNotCover + ); +} + +#[test] +fn every_verdict_is_scoped_to_the_window_it_names() { + let window = window(&[]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(report.window(), ("20260801", "20260831")); +} + +// --- the numbering method is declared ---------------------------------- + +#[test] +fn an_undeclared_numbering_method_is_an_error_not_a_default() { + let window = window(&[]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .voucher_type("Part and Labour Sale") + .build()]; + assert_eq!( + PresenceRequest::new( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals + ) + .expect_err("undeclared"), + PresenceError::NumberingMethodUndeclared + ); +} + +#[test] +fn declaring_one_voucher_type_two_ways_is_refused() { + assert_eq!( + NumberingDeclaration::new([ + ("Sales", NumberingMethod::Manual), + ("sales", NumberingMethod::Automatic), + ]) + .expect_err("conflict"), + PresenceError::NumberingMethodConflict + ); +} + +#[test] +fn a_repeated_identical_declaration_is_accepted() { + assert!(NumberingDeclaration::new([ + ("Sales", NumberingMethod::Manual), + ("Sales", NumberingMethod::Manual), + ]) + .is_ok()); +} + +// --- only identity produces Present ------------------------------------ + +#[test] +fn a_manual_voucher_number_unique_on_both_sides_decides_presence() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert_eq!(entry.present_book_key(), Some("book-1")); + assert!(matches!( + entry.status, + PresenceStatus::Present { + basis: PresenceBasis::ManualVoucherNumber, + .. + } + )); +} + +#[test] +fn a_voucher_number_decides_nothing_under_automatic_numbering() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Automatic), + &proposals, + ); + let entry = only(&report); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::NumberNotDecisive); +} + +#[test] +fn a_voucher_number_decides_nothing_under_unknown_numbering() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Unknown), + &proposals, + ); + assert_eq!(reason(only(&report)), UndecidedReason::NumberNotDecisive); +} + +#[test] +fn a_voucher_number_is_compared_on_the_same_key_as_a_master_name() { + let window = window(&[BookRow::new("book-1", "20260812", "aa-0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA-0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(only(&report).present_book_key(), Some("book-1")); +} + +#[test] +fn a_number_carried_by_two_book_vouchers_decides_nothing() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118"), + BookRow::new("book-2", "20260814", "AA0118").party("Bravo Industries"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert_eq!(reason(entry), UndecidedReason::BookNumberCollision); + assert_eq!(entry.undecided().expect("undecided").candidates.len(), 2); +} + +#[test] +fn a_number_claimed_by_two_proposals_decides_nothing() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ + ProposalRow::new(0, "20260812", "AA0118").build(), + ProposalRow::new(1, "20260813", "AA0118") + .party("Bravo Industries") + .build(), + ]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + for entry in report.vouchers() { + assert_eq!(reason(entry), UndecidedReason::ProposalNumberCollision); + } +} + +#[test] +fn a_remote_id_unique_on_both_sides_decides_presence() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").remote_id("bridge-txn-1")]); + let proposals = [ProposalRow::new(0, "20260814", "AA9999") + .remote_id("bridge-txn-1") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Automatic), + &proposals, + ); + assert!(matches!( + only(&report).status, + PresenceStatus::Present { + basis: PresenceBasis::RemoteId, + .. + } + )); +} + +#[test] +fn a_remote_id_on_two_book_vouchers_decides_nothing() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118").remote_id("bridge-txn-1"), + BookRow::new("book-2", "20260813", "AA0119").remote_id("bridge-txn-1"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .remote_id("bridge-txn-1") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(reason(only(&report)), UndecidedReason::RemoteIdCollision); +} + +#[test] +fn an_absent_remote_id_column_is_reported_so_no_match_is_not_read_as_evidence() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert!(!report.observations().remote_id_observed); +} + +#[test] +fn a_manual_number_never_decides_across_an_unobserved_voucher_type() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").voucher_type("Parts Sale")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .voucher_type("Part Sale") + .build()]; + let declaration = + NumberingDeclaration::new([("Part Sale", NumberingMethod::Manual)]).expect("numbering"); + let report = run(&window, &catalog(), &declaration, &proposals); + let entry = only(&report); + assert!(!entry.voucher_type_observed); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::VoucherTypeNotObserved); + assert_eq!(entry.undecided().expect("undecided").candidates.len(), 1); +} + +// --- cancelled and optional vouchers ----------------------------------- + +#[test] +fn an_identity_match_on_a_cancelled_voucher_is_never_present() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").cancelled()]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::MatchedVoucherNotPosted); +} + +#[test] +fn an_identity_match_on_an_optional_voucher_is_never_present() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").optional()]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!( + reason(only(&report)), + UndecidedReason::MatchedVoucherNotPosted + ); +} + +// --- Present reports what disagrees ------------------------------------ + +#[test] +fn a_present_voucher_reports_an_amount_the_book_posted_short() { + // One 9% head dropped when the voucher was keyed by hand. + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").rows(vec![ + ["Alpha Traders", "-10900.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ])]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + assert_eq!(differences.len(), 1); + assert_eq!(differences[0].field, DifferenceField::Amount); + // Magnitudes are reported in canonical exact-decimal form, so a scale-only + // difference between two readings of one amount is never a difference. + assert_eq!(differences[0].proposed.as_deref(), Some("11800")); + assert_eq!(differences[0].observed.as_deref(), Some("10900")); +} + +#[test] +fn a_present_voucher_reports_a_date_the_book_disagrees_with() { + let window = window(&[BookRow::new("book-1", "20260825", "AA0309")]); + let proposals = [ProposalRow::new(0, "20260829", "AA0309").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + assert_eq!(differences.len(), 1); + assert_eq!(differences[0].field, DifferenceField::Date); +} + +#[test] +fn a_present_voucher_reports_a_party_the_book_disagrees_with() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").party("Bravo Industries")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + let party = differences + .iter() + .find(|difference| difference.field == DifferenceField::Party) + .expect("party difference"); + assert_eq!(party.proposed.as_deref(), Some("Alpha Traders")); + assert_eq!(party.observed.as_deref(), Some("Bravo Industries")); +} + +#[test] +fn an_agreeing_present_voucher_reports_no_differences() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + assert!(differences.is_empty()); +} + +// --- resemblance never decides ----------------------------------------- + +#[test] +fn date_party_and_amount_together_still_only_resemble() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::ResemblesBookVoucher); + assert_eq!( + entry.undecided().expect("undecided").candidates[0].rule, + CandidateRule::SameDatePartyAmount + ); +} + +#[test] +fn an_amount_recurring_across_unrelated_parties_only_resembles() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").party("Bravo Industries")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert_eq!( + entry.undecided().expect("undecided").candidates[0].rule, + CandidateRule::SameDateAmount + ); +} + +#[test] +fn no_candidate_is_marked_best_and_no_score_is_emitted() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118"), + BookRow::new("book-2", "20260812", "AA0119"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let serialized = serde_json::to_value(only(&report)).expect("serialize"); + let object = serialized.as_object().expect("object"); + // The refusal carries no field that names a match. A status does not + // disarm a value printed beside it, so there must be no such value. + assert_eq!( + object.get("presence").and_then(serde_json::Value::as_str), + Some("possibly_present") + ); + for forbidden in [ + "book_key", + "basis", + "best", + "score", + "preferred", + "suggested", + ] { + assert!(object.get(forbidden).is_none(), "{forbidden} leaked"); + } + let text = serialized.to_string(); + assert!(!text.contains("score")); +} + +#[test] +fn candidates_are_ordered_by_rule_then_key_and_never_by_similarity() { + let window = window(&[ + BookRow::new("book-2", "20260812", "AA0118"), + BookRow::new("book-1", "20260819", "AA0119"), + ]); + // book-2 shares date, party and amount; book-1 shares party and amount. + let proposals = [ProposalRow::new(0, "20260812", "AA0999").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let candidates = &only(&report).undecided().expect("undecided").candidates; + assert_eq!(candidates[0].rule, CandidateRule::SameDatePartyAmount); + assert_eq!(candidates[0].book_key, "book-2"); + assert_eq!(candidates[1].rule, CandidateRule::SamePartyAmount); +} + +// --- party matching is master_binding ----------------------------------- + +#[test] +fn a_party_binds_on_an_embedded_identifier_before_any_name() { + let names = [ + "Alpha (5550000001)", + "Alpha Traders", + "Sales Account", + "Output CGST 9%", + "Output SGST 9%", + ]; + let window = window(&[BookRow::new("book-1", "20260812", "AA0118") + .party("Alpha (5550000001)") + .rows(vec![ + ["Alpha (5550000001)", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ["Output SGST 9%", "900.00"], + ])]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999") + .party("ALPHA. BRAVO 5550000001") + .rows(vec![ + ["ALPHA. BRAVO 5550000001", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ["Output SGST 9%", "900.00"], + ]) + .build()]; + let report = run( + &window, + &catalog_of(&names), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert_eq!( + entry.party, + PartyOutcome::Bound { + catalog_name: "Alpha (5550000001)".to_string() + } + ); + assert_eq!( + entry.undecided().expect("undecided").candidates[0].rule, + CandidateRule::SameDatePartyAmount + ); +} + +#[test] +fn an_ambiguous_party_is_compared_against_every_candidate_never_one() { + let names = [ + "Delta Trading Company", + "Delta Trading Corporation", + "Sales Account", + "Output CGST 9%", + "Output SGST 9%", + ]; + let window = window(&[BookRow::new("book-1", "20260819", "AA0118") + .party("Delta Trading Corporation") + .rows(vec![ + ["Delta Trading Corporation", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ["Output SGST 9%", "900.00"], + ])]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999") + .party("Delta Trading") + .rows(vec![ + ["Delta Trading", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ["Output SGST 9%", "900.00"], + ]) + .build()]; + let report = run( + &window, + &catalog_of(&names), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(matches!(entry.party, PartyOutcome::Ambiguous { .. })); + // The date differs, so only a party-and-amount rule can fire — and it only + // fires because both candidate names were compared. + assert_eq!( + entry.undecided().expect("undecided").candidates[0].rule, + CandidateRule::SamePartyAmount + ); +} + +#[test] +fn a_party_with_nothing_resembling_it_still_permits_absent() { + let window = window(&[BookRow::new("book-1", "20260819", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999") + .party("Zulu Enterprises") + .rows(vec![ + ["Zulu Enterprises", "-22500.00"], + ["Sales Account", "22500.00"], + ]) + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(matches!(entry.party, PartyOutcome::Unmatched { .. })); + assert!(entry.is_absent()); +} + +#[test] +fn a_party_name_family_withholds_absent_because_the_comparison_never_ran() { + let mut names: Vec = (1..=30) + .map(|index| format!("Echo Party {index:03}")) + .collect(); + names.extend( + ["Sales Account", "Output CGST 9%", "Output SGST 9%"] + .iter() + .map(|name| (*name).to_string()), + ); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("catalog"); + let window = window(&[]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999") + .party("Echo Party 0") + .rows(vec![ + ["Echo Party 0", "-22500.00"], + ["Sales Account", "22500.00"], + ]) + .build()]; + let report = run( + &window, + &catalog, + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(!entry.is_absent(), "an uncompared family is not an absence"); + assert_eq!(reason(entry), UndecidedReason::PartyNotDecidable); + assert!(entry.undecided().expect("undecided").candidates.is_empty()); +} + +#[test] +fn a_proposal_without_a_party_still_runs_the_party_independent_rules() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let mut proposal = ProposalRow::new(0, "20260812", "AA0999"); + proposal.party = None; + let proposals = [proposal.build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert_eq!(entry.party, PartyOutcome::NotSupplied); + assert_eq!( + entry.undecided().expect("undecided").candidates[0].rule, + CandidateRule::SameDateAmount + ); +} + +// --- magnitude --------------------------------------------------------- + +#[test] +fn both_sides_derive_one_magnitude_from_the_same_entries() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").rows(vec![ + ["Alpha Traders", "-11800"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.000"], + ["Output SGST 9%", "900"], + ])]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!( + only(&report).undecided().expect("undecided").candidates[0].rule, + CandidateRule::SameDatePartyAmount + ); +} + +#[test] +fn an_unbalanced_book_voucher_is_reported_and_still_matched() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").rows(vec![ + ["Alpha Traders", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ])]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(report.observations().unbalanced_voucher_count, 1); + assert_eq!(report.observations().unbalanced_vouchers, vec!["book-1"]); + assert_eq!(only(&report).present_book_key(), Some("book-1")); +} + +// --- book observations -------------------------------------------------- + +#[test] +fn duplicate_voucher_numbers_in_the_book_are_reported_without_being_asked_for() { + let window = window(&[ + BookRow::new("book-1", "20260803", "AA0118"), + BookRow::new("book-2", "20260814", "AA0118").party("Bravo Industries"), + BookRow::new("book-3", "20260815", "AA0120").party("Charlie Minerals"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let observations = report.observations(); + assert_eq!(observations.duplicate_number_group_count, 1); + assert!(!observations.duplicate_numbers_truncated); + let group = &observations.duplicate_numbers[0]; + assert_eq!(group.voucher_number, "AA0118"); + assert_eq!(group.book_voucher_count, 2); + assert_eq!(group.book_keys, vec!["book-1", "book-2"]); +} + +#[test] +fn book_vouchers_no_proposal_reached_are_counted() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118"), + BookRow::new("book-2", "20260819", "AA0119").party("Charlie Minerals"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(report.observations().unmatched_book_vouchers, 1); + assert_eq!(report.observations().window_voucher_count, 2); +} + +// --- control totals ----------------------------------------------------- + +#[test] +fn totals_reconcile_a_mixed_run_and_reproduce_the_engagement_that_blocked() { + // Twenty invoices proposed; fifteen already keyed by hand. + let book: Vec = (1..=15) + .map(|index| { + BookRow::new( + Box::leak(format!("book-{index}").into_boxed_str()), + "20260812", + Box::leak(format!("AA{index:04}").into_boxed_str()), + ) + }) + .collect(); + let window = window(&book); + let proposals: Vec = (1..=20) + .map(|index| { + ProposalRow::new( + index - 1, + "20260812", + Box::leak(format!("AA{index:04}").into_boxed_str()), + ) + .rows(vec![ + ["Alpha Traders", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ["Output SGST 9%", "900.00"], + ]) + .build() + }) + .collect(); + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let totals = report.totals(); + assert_eq!(totals.requested, 20); + assert_eq!(totals.present, 15); + // The five that are not in the book all resemble the fifteen that are — + // same party, same date, same amount — so none is silently absent. + assert_eq!(totals.present + totals.possibly_present + totals.absent, 20); + assert_eq!(totals.possibly_present, 5); + assert_eq!(totals.absent, 0); +} + +#[test] +fn totals_always_partition_the_requested_set() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118"), + BookRow::new("book-2", "20260812", "AA0119").party("Bravo Industries"), + ]); + let proposals = [ + ProposalRow::new(0, "20260812", "AA0118").build(), + ProposalRow::new(1, "20260813", "AA0125") + .party("Charlie Minerals") + .build(), + ProposalRow::new(2, "20260812", "AA0126").build(), + ]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let totals = report.totals(); + assert_eq!(totals.requested, 3); + assert_eq!( + totals.present + totals.possibly_present + totals.absent, + totals.requested + ); + assert_eq!(report.absent().count(), totals.absent); + assert_eq!(report.present().count(), totals.present); + assert_eq!(report.possibly_present().count(), totals.possibly_present); +} + +// --- boundary refusals -------------------------------------------------- + +#[test] +fn an_empty_proposal_set_is_refused() { + let window = window(&[]); + assert_eq!( + PresenceRequest::new( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &[] + ) + .expect_err("empty"), + PresenceError::ProposalsEmpty + ); +} + +#[test] +fn a_stock_item_catalog_cannot_be_used_to_compare_parties() { + let catalog = MasterCatalog::new(MasterClass::StockItem, LEDGERS).expect("catalog"); + let window = window(&[]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + assert_eq!( + PresenceRequest::new( + &window, + &catalog, + &numbering(NumberingMethod::Manual), + &proposals + ) + .expect_err("class"), + PresenceError::CatalogClassInvalid + ); +} + +#[test] +fn observed_input_refuses_blank_unsafe_and_invalid_fields() { + let rows = [["Alpha Traders", "-1.00"], ["Sales Account", "1.00"]]; + let good = entries(&rows); + let base = ObservedVoucher { + key: "book-1", + date: "20260812", + voucher_type: "Sales", + voucher_number: Some("AA0118"), + remote_id: None, + party: Some("Alpha Traders"), + entries: &good, + cancelled: false, + optional: false, + }; + assert_eq!( + BookVoucher::observed(ObservedVoucher { key: " ", ..base }).expect_err("blank"), + PresenceError::TextBlank + ); + assert_eq!( + BookVoucher::observed(ObservedVoucher { + voucher_type: "Sales\u{0007}", + ..base + }) + .expect_err("unsafe"), + PresenceError::TextUnsafe + ); + assert_eq!( + BookVoucher::observed(ObservedVoucher { + date: "2026-08-12", + ..base + }) + .expect_err("date"), + PresenceError::DateInvalid + ); + let bad = [["Alpha Traders", "one thousand"]]; + let bad = entries(&bad); + assert_eq!( + BookVoucher::observed(ObservedVoucher { + entries: &bad, + ..base + }) + .expect_err("amount"), + PresenceError::AmountInvalid + ); +} + +#[test] +fn every_error_carries_a_distinct_stable_reason_code() { + let codes = [ + PresenceError::WindowIncomplete, + PresenceError::WindowRangeInvalid, + PresenceError::WindowTooLarge, + PresenceError::WindowVoucherOutsideRange, + PresenceError::WindowDuplicateVoucherKey, + PresenceError::WindowDoesNotCover, + PresenceError::ProposalsEmpty, + PresenceError::TooManyProposals, + PresenceError::TooManyEntries, + PresenceError::NumberingMethodUndeclared, + PresenceError::NumberingMethodConflict, + PresenceError::TextBlank, + PresenceError::TextTooLong, + PresenceError::TextUnsafe, + PresenceError::DateInvalid, + PresenceError::AmountInvalid, + PresenceError::CatalogClassInvalid, + ] + .iter() + .map(PresenceError::safe_reason_code) + .collect::>(); + assert_eq!(codes.len(), 17); + assert!(codes.iter().all(|code| code.starts_with("presence_"))); +} + +#[test] +fn every_undecided_reason_carries_a_distinct_stable_code() { + let codes = [ + UndecidedReason::RemoteIdCollision, + UndecidedReason::BookNumberCollision, + UndecidedReason::ProposalNumberCollision, + UndecidedReason::MatchedVoucherNotPosted, + UndecidedReason::NumberNotDecisive, + UndecidedReason::VoucherTypeNotObserved, + UndecidedReason::ResemblesBookVoucher, + UndecidedReason::PartyNotDecidable, + ] + .iter() + .map(|reason| reason.safe_reason_code()) + .collect::>(); + assert_eq!(codes.len(), 8); +} diff --git a/src-tauri/crates/bridge-tally-core/src/lib.rs b/src-tauri/crates/bridge-tally-core/src/lib.rs index 9860d52d..7017843d 100644 --- a/src-tauri/crates/bridge-tally-core/src/lib.rs +++ b/src-tauri/crates/bridge-tally-core/src/lib.rs @@ -8,6 +8,7 @@ pub use bridge_tally_primitives::{ }; pub mod bills_reconciliation; +pub mod book_presence; pub mod master_binding; mod pack_models; pub mod reconciliation; diff --git a/src-tauri/src/agent.rs b/src-tauri/src/agent.rs index e4320201..fb8bd015 100644 --- a/src-tauri/src/agent.rs +++ b/src-tauri/src/agent.rs @@ -31,6 +31,8 @@ mod changes; mod ledgers; #[path = "agent_outstandings.rs"] mod outstandings; +#[path = "agent_presence.rs"] +mod presence; #[path = "agent_vouchers.rs"] mod vouchers; #[cfg(test)] @@ -671,6 +673,7 @@ impl Server { "verify_import" => self.verify_import(args).await, "ledger_masters" => self.ledger_masters(args).await, "vouchers" => self.vouchers(args).await, + "voucher_presence" => self.voucher_presence(args).await, "changed_since" => self.changed_since(args).await, "outstandings" => self.outstandings(args).await, "ledger_movement" => self.ledger_movement(args).await, diff --git a/src-tauri/src/agent_catalog.rs b/src-tauri/src/agent_catalog.rs index c3e6935d..59f85822 100644 --- a/src-tauri/src/agent_catalog.rs +++ b/src-tauri/src/agent_catalog.rs @@ -129,6 +129,7 @@ pub(super) fn registered_tool_definitions(import_enabled: bool, writes_enabled: "ledger_movement", "trial_balance", "vouchers", + "voucher_presence", "changed_since", "read_evidence", "egress_log", @@ -191,6 +192,23 @@ pub(super) fn registered_tool_definitions(import_enabled: bool, writes_enabled: "Return literal-window voucher evidence with curated metadata and redaction. Reads the full source window before selectors and output pagination; limit does not reduce Tally work. Use narrow dates; dense windows are unqualified and can fail source limits.", json!({"type":"object","additionalProperties":false,"required":["company_guid","from","to"],"properties":{"company_guid":{"type":"string","minLength":1},"from":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"},"to":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"},"voucher_type":{"type":"string","maxLength":agent_import::MAX_MASTER_NAME_CHARS},"ledger":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"},"offset":{"type":"integer","minimum":0,"default":0},"limit":{"type":"integer","minimum":1,"default":500}}}), ), + "voucher_presence" => ( + "Answer which of 1\u{2013}500 proposed vouchers are already in the book, over one literal window. Tally has no idempotency, so re-sending a voucher creates a second one. `presence` is present, possibly_present or absent, and only `present` names a book voucher. Only identity decides: a shared REMOTEID, or a voucher number on a voucher type you declare `manual` \u{2014} unique on both sides, within an observed voucher type, and never onto a cancelled or optional voucher. Date, party and amount only ever produce candidates, with the rule that surfaced each and no ranking or score. Every voucher type a proposal names needs a declared numbering method; under `automatic` Tally discards the supplied number, so nothing can be decided from it. `absent` means absent from this window, so cover the dates the book could hold. Reads the full window before comparing; dense windows can fail source limits. Party names bind through the same rules as validate_masters. This never dispatches import XML to Tally.", + json!({"type":"object","additionalProperties":false,"required":["company_guid","from","to","numbering","vouchers"],"properties":{ + "company_guid":{"type":"string","minLength":1}, + "from":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"}, + "to":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"}, + "numbering":{"type":"array","minItems":1,"maxItems":presence::MAX_PRESENCE_VOUCHER_TYPES,"items":{"type":"object","additionalProperties":false,"required":["voucher_type","numbering_method"],"properties":{"voucher_type":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"},"numbering_method":{"type":"string","enum":["manual","automatic","unknown"]}}}}, + "vouchers":{"type":"array","minItems":1,"maxItems":presence::MAX_PRESENCE_VOUCHERS,"items":{"type":"object","additionalProperties":false,"required":["date","voucher_type","entries"],"properties":{ + "date":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"}, + "voucher_type":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"}, + "voucher_number":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"}, + "remote_id":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"}, + "party":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"}, + "entries":{"type":"array","minItems":1,"maxItems":presence::MAX_PRESENCE_ENTRIES,"items":{"type":"object","additionalProperties":false,"required":["ledger","amount"],"properties":{"ledger":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"},"amount":{"type":"string","minLength":1,"maxLength":64,"pattern":r"\S"}}}} + }}} + }}), + ), "changed_since" => ( "Return snapshot-pinned AlterID voucher and master evidence. Continue a truncated scan with both returned AlterID cursors and snapshot values; deletion detection remains unsupported.", json!({"type":"object","additionalProperties":false,"required":["company_guid"],"properties":{"company_guid":{"type":"string","minLength":1},"voucher_alter_id":{"type":"integer","minimum":0,"default":0},"master_alter_id":{"type":"integer","minimum":0,"default":0},"voucher_snapshot_alter_id":{"type":"integer","minimum":0},"master_snapshot_alter_id":{"type":"integer","minimum":0}}}), diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs new file mode 100644 index 00000000..7e432dca --- /dev/null +++ b/src-tauri/src/agent_presence.rs @@ -0,0 +1,276 @@ +//! "Which of these are already in the book?" for the local MCP adapter. +//! +//! The rules live in `bridge_tally_core::book_presence` so this tool and any +//! later desktop screen cannot drift apart; see +//! `docs/adr/0017-voucher-presence-authority.md`. This file owns only the two +//! qualified reads that produce the evidence, the typed parse of the caller's +//! proposals, and the response shape. +use super::*; + +use bridge_tally_core::book_presence::{ + self, BookVoucher, BookWindow, NumberingDeclaration, NumberingMethod, ObservedEntry, + ObservedVoucher, PresenceError, PresenceReport, PresenceRequest, ProposedVoucher, + ProposedVoucherInput, WindowRead, +}; +use bridge_tally_core::master_binding::{MasterCatalog, MasterClass}; + +/// Most vouchers one presence request may propose. The window read is +/// unaffected by this: it always reads its whole range. +pub(super) const MAX_PRESENCE_VOUCHERS: usize = 500; +/// Most voucher types one numbering declaration may name. +pub(super) const MAX_PRESENCE_VOUCHER_TYPES: usize = 50; +/// Most ledger entries one proposed voucher may carry. +pub(super) const MAX_PRESENCE_ENTRIES: usize = 200; + +impl Server { + pub(super) async fn voucher_presence(&self, args: &Value) -> Result { + let guid = required_string(args, "company_guid")?; + let from = normalized_date(required_string(args, "from")?)?; + let to = normalized_date(required_string(args, "to")?)?; + if from > to { + return Err("invalid_date_range".to_string().into()); + } + // Parse the caller's own input before any Tally read: a malformed + // proposal set should never cost a read. + let numbering = parse_numbering(args)?; + let proposals = parse_proposals(args)?; + + let (company, identity, accumulated) = self.verified_company(guid).await?; + let mut accumulated = Some(accumulated); + let outcome = async { + let (catalogue, catalogue_evidence) = + self.read_ledger_catalogue(&identity, &company.name).await?; + accumulate(&mut accumulated, catalogue_evidence); + let catalog = MasterCatalog::new(MasterClass::Ledger, &catalogue) + .map_err(|error| error.safe_reason_code().to_string())?; + + let request = render_agent_vouchers(&company.name, &from, &to, None)?; + let (xml, evidence) = self.post_read(&identity, request).await?; + accumulate(&mut accumulated, evidence); + let rows = validate_then_filter_voucher_rows( + parse_agent_rows(&xml, identity.company_guid())?, + &from, + &to, + None, + )?; + + // An empty window is only an empty window once the existing + // corroboration says so. Anything less becomes `WindowIncomplete` + // at the crate boundary rather than a report full of "absent". + let mut read = WindowRead::Complete; + let mut reason = None; + if rows.is_empty() { + let (read_evidence, partial, corroboration) = self + .corroborate_empty_voucher_read(&identity, &company.name, &from, &to, None) + .await?; + accumulate(&mut accumulated, read_evidence); + reason = corroboration; + if partial { + read = WindowRead::Partial; + if let Some(evidence) = accumulated.as_mut() { + evidence.state = "partial"; + evidence.reason_code = corroboration.map(str::to_string); + } + } + } + + let observed = rows + .iter() + .map(book_voucher) + .collect::, _>>() + .map_err(presence_code)?; + let window = BookWindow::observed(&from, &to, read, observed).map_err(presence_code)?; + let request = PresenceRequest::new(&window, &catalog, &numbering, &proposals) + .map_err(presence_code)?; + let report = book_presence::assess(&request); + + Ok(ToolOutcome { + payload: json!({ + "company": company_json(&company, std::slice::from_ref(&company)), + "result": presence_result(&report, &catalogue, reason), + }), + evidence: accumulated + .clone() + .expect("presence evidence is present after admitted reads"), + company_guid: Some(guid.to_string()), + truncated: false, + }) + } + .await; + outcome.map_err(|failure: ToolFailure| match accumulated { + Some(evidence) => failure.with_prior_evidence(evidence), + None => failure, + }) + } +} + +fn accumulate(target: &mut Option, next: Evidence) { + *target = Some(match target.take() { + Some(current) => combine_evidence(current, next), + None => next, + }); +} + +fn presence_code(error: PresenceError) -> ToolFailure { + error.safe_reason_code().to_string().into() +} + +/// Turns one validated voucher row from the qualified window read into an +/// observed book voucher. `REMOTEID` is deliberately not read here: the +/// `vouchers` profile does not fetch it, and inventing an absent column would +/// be worse than reporting that it was never observed. +fn book_voucher(row: &Value) -> Result { + let entries = row["amounts"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .map(|entry| ObservedEntry { + ledger: entry["ledger"].as_str().unwrap_or_default(), + amount: entry["amount"].as_str().unwrap_or_default(), + }) + .collect::>(); + BookVoucher::observed(ObservedVoucher { + // The GUID is the identity the window read already proved belongs to + // this company, and the same field this tool's sibling already emits. + key: row["guid"].as_str().unwrap_or_default(), + date: row["date"].as_str().unwrap_or_default(), + voucher_type: row["voucher_type"].as_str().unwrap_or_default(), + voucher_number: row["voucher_number"].as_str(), + remote_id: None, + party: row["party"].as_str(), + entries: &entries, + cancelled: row["cancelled"].as_bool().unwrap_or_default(), + optional: row["optional"].as_bool().unwrap_or_default(), + }) +} + +fn parse_numbering(args: &Value) -> Result { + let declared = args + .get("numbering") + .and_then(Value::as_array) + .ok_or_else(|| "numbering_required".to_string())?; + if declared.is_empty() || declared.len() > MAX_PRESENCE_VOUCHER_TYPES { + return Err("argument_invalid:numbering".to_string()); + } + let entries = declared + .iter() + .map(|entry| { + let voucher_type = entry + .get("voucher_type") + .and_then(Value::as_str) + .ok_or_else(|| "argument_invalid:numbering".to_string())?; + let method = match entry.get("numbering_method").and_then(Value::as_str) { + Some("manual") => NumberingMethod::Manual, + Some("automatic") => NumberingMethod::Automatic, + Some("unknown") => NumberingMethod::Unknown, + _ => return Err("argument_invalid:numbering".to_string()), + }; + Ok((voucher_type.to_string(), method)) + }) + .collect::, String>>()?; + NumberingDeclaration::new(entries).map_err(|error| error.safe_reason_code().to_string()) +} + +fn parse_proposals(args: &Value) -> Result, String> { + let proposed = args + .get("vouchers") + .and_then(Value::as_array) + .ok_or_else(|| "vouchers_required".to_string())?; + if proposed.is_empty() || proposed.len() > MAX_PRESENCE_VOUCHERS { + return Err("argument_invalid:vouchers".to_string()); + } + let mut parsed = Vec::with_capacity(proposed.len()); + for (position, voucher) in proposed.iter().enumerate() { + let date = normalized_date( + voucher + .get("date") + .and_then(Value::as_str) + .ok_or_else(|| "argument_invalid:vouchers".to_string())?, + )?; + let voucher_type = voucher + .get("voucher_type") + .and_then(Value::as_str) + .ok_or_else(|| "argument_invalid:vouchers".to_string())?; + let rows = voucher + .get("entries") + .and_then(Value::as_array) + .filter(|entries| !entries.is_empty() && entries.len() <= MAX_PRESENCE_ENTRIES) + .ok_or_else(|| "argument_invalid:vouchers".to_string())?; + let entries = rows + .iter() + .map(|entry| { + Ok(ObservedEntry { + ledger: entry + .get("ledger") + .and_then(Value::as_str) + .ok_or_else(|| "argument_invalid:vouchers".to_string())?, + amount: entry + .get("amount") + .and_then(Value::as_str) + .ok_or_else(|| "argument_invalid:vouchers".to_string())?, + }) + }) + .collect::, String>>()?; + parsed.push( + ProposedVoucher::new(ProposedVoucherInput { + position, + date: &date, + voucher_type, + voucher_number: voucher.get("voucher_number").and_then(Value::as_str), + remote_id: voucher.get("remote_id").and_then(Value::as_str), + party: voucher.get("party").and_then(Value::as_str), + entries: &entries, + }) + .map_err(|error| error.safe_reason_code().to_string())?, + ); + } + Ok(parsed) +} + +fn presence_result( + report: &PresenceReport, + catalogue: &[String], + corroboration_reason: Option<&'static str>, +) -> Value { + let (from, to) = report.window(); + let vouchers = report + .vouchers() + .iter() + .map(|entry| mark_presence_party_names(serde_json::to_value(entry).unwrap_or_default())) + .collect::>(); + json!({ + "profile": "agent_voucher_presence_v1", + // Every verdict is relative to this window. `absent` means absent from + // this range and never absent from the book. + "window": {"from": from, "to": to, "read": "complete", "reason": corroboration_reason}, + "vouchers": vouchers, + "totals": report.totals(), + "book": report.observations(), + "catalogue_evidence_sha256": sha256_json(&catalogue.to_vec()), + }) +} + +/// Marks the names an egress policy treats as party data. Voucher numbers and +/// dates are accounting selectors the sibling voucher read already emits +/// unmarked; the names are not. +pub(super) fn mark_presence_party_names(mut entry: Value) -> Value { + if let Some(party) = entry.get_mut("party") { + mark_party_field(party, "catalog_name"); + } + if let Some(differences) = entry.get_mut("differences").and_then(Value::as_array_mut) { + for difference in differences { + if difference.get("field").and_then(Value::as_str) != Some("party") { + continue; + } + for side in ["proposed", "observed"] { + mark_party_field(difference, side); + } + } + } + entry +} + +#[cfg(test)] +#[path = "agent_presence_tests.rs"] +mod tests; diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs new file mode 100644 index 00000000..aeeda0e0 --- /dev/null +++ b/src-tauri/src/agent_presence_tests.rs @@ -0,0 +1,404 @@ +//! Every company, ledger, party and voucher number below is fabricated or is +//! the repository's existing synthetic capture. Nothing here is evidence about +//! a real book. +use super::*; +use bridge_tally_transport::TallyEndpointConfig; +use tally_protocol_simulator::{ + Fixture, ResponseFraming, ScenarioPlan, SequenceSimulator, WireEncoding, +}; + +const CAPTURED_GUID: &str = "61c6de69-1748-461c-ad3f-162cb949df9f"; +const GUID: &str = "00000000-0000-4000-8000-000000000001"; + +fn offline_server(directory: &std::path::Path) -> Server { + Server::new(Settings { + endpoint: TallyEndpointConfig { + host: "127.0.0.1".into(), + port: 9, + }, + data_dir: directory.to_path_buf(), + max_rows: 500, + max_bytes: 200_000, + redaction: Redaction::None, + import_enabled: false, + writes_enabled: false, + }) +} + +fn proposal(number: &str, party: &str, total: &str) -> Value { + json!({ + "date": "20260901", + "voucher_type": "Journal", + "voucher_number": number, + "party": party, + "entries": [ + {"ledger": party, "amount": format!("-{total}")}, + {"ledger": "WR2 Sales", "amount": total}, + ], + }) +} + +fn args(vouchers: Value, numbering: &str) -> Value { + json!({ + "company_guid": CAPTURED_GUID, + "from": "20260901", + "to": "20260930", + "numbering": [{"voucher_type": "Journal", "numbering_method": numbering}], + "vouchers": vouchers, + }) +} + +// --- admission, before any Tally read ---------------------------------- + +#[tokio::test] +async fn presence_arguments_are_bounded_before_any_tally_probe() { + let directory = tempfile::tempdir().expect("directory"); + let server = offline_server(directory.path()); + let base = proposal("JV-1", "Cash", "12.50"); + for (arguments, code) in [ + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}]}), + "vouchers_required", + ), + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "vouchers":[base.clone()]}), + "numbering_required", + ), + ( + json!({"company_guid":GUID,"from":"20260930","to":"20260901", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[base.clone()]}), + "invalid_date_range", + ), + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"sometimes"}], + "vouchers":[base.clone()]}), + "argument_invalid:numbering", + ), + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[]}), + "argument_invalid:vouchers", + ), + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[{"date":"20260901","voucher_type":"Journal","entries":[]}]}), + "argument_invalid:vouchers", + ), + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[{"date":"20260901","voucher_type":"Journal", + "entries":[{"ledger":"Cash","amount":"maybe"}]}]}), + "presence_amount_invalid", + ), + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[base.clone()],"ledger":"Cash"}), + "argument_unknown", + ), + ] { + let response = server + .call_tool_response("voucher_presence", arguments) + .await; + assert_eq!(response.value["isError"], true, "{code}"); + assert_eq!( + response.value["structuredContent"]["result"]["error"]["code"], code, + "{code}" + ); + // Nothing may cost a Tally read. + assert_eq!(response.value["structuredContent"]["evidence"]["bytes"], 0); + } +} + +#[tokio::test] +async fn an_over_large_proposal_set_is_refused_rather_than_trimmed() { + let directory = tempfile::tempdir().expect("directory"); + let server = offline_server(directory.path()); + let vouchers = vec![proposal("JV-1", "Cash", "12.50"); MAX_PRESENCE_VOUCHERS + 1]; + let response = server + .call_tool_response("voucher_presence", args(json!(vouchers), "manual")) + .await; + assert_eq!( + response.value["structuredContent"]["result"]["error"]["code"], + "argument_invalid:vouchers" + ); +} + +#[test] +fn the_published_schema_names_the_three_numbering_methods_and_its_bounds() { + let definitions = tool_definitions(true, false); + let tool = definitions + .as_array() + .and_then(|tools| tools.iter().find(|tool| tool["name"] == "voucher_presence")) + .expect("voucher_presence tool definition"); + let schema = &tool["inputSchema"]; + assert_eq!( + schema["properties"]["numbering"]["items"]["properties"]["numbering_method"]["enum"], + json!(["manual", "automatic", "unknown"]) + ); + assert_eq!( + schema["properties"]["vouchers"]["maxItems"], + json!(MAX_PRESENCE_VOUCHERS) + ); + assert_eq!( + schema["required"], + json!(["company_guid", "from", "to", "numbering", "vouchers"]) + ); + // The tool reads; it must not be annotated as a write. + assert!(tool.get("annotations").is_none()); +} + +#[test] +fn an_unknown_numbering_method_is_refused_at_the_published_schema() { + assert_eq!( + validate_tool_arguments( + "voucher_presence", + &json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[],"vouchers":[proposal("JV-1","Cash","12.50")]}), + ), + Err("argument_invalid:numbering".to_string()) + ); +} + +// --- typed parses ------------------------------------------------------- + +#[test] +fn one_voucher_type_declared_two_ways_is_refused_before_a_read() { + assert_eq!( + parse_numbering(&json!({"numbering":[ + {"voucher_type":"Journal","numbering_method":"manual"}, + {"voucher_type":"journal","numbering_method":"automatic"}, + ]})) + .expect_err("conflict"), + "presence_numbering_method_conflict".to_string() + ); +} + +#[test] +fn a_window_row_becomes_a_book_voucher_without_inventing_a_remote_id() { + let row = json!({ + "guid": format!("{CAPTURED_GUID}-00000001"), + "date": "20260901", + "voucher_number": "JV-1", + "voucher_type": "Journal", + "party": "Bridge Nested Debtor WR4", + "cancelled": false, + "optional": false, + "amounts": [ + {"ledger": "Bridge Nested Debtor WR4", "amount": "-12.50"}, + {"ledger": "WR2 Sales", "amount": "12.50"}, + ], + }); + let voucher = book_voucher(&row).expect("book voucher"); + assert_eq!(voucher.key(), format!("{CAPTURED_GUID}-00000001")); + assert_eq!(voucher.magnitude().as_str(), "12.5"); + assert!(voucher.balanced()); + assert_eq!(voucher.party(), Some("Bridge Nested Debtor WR4")); +} + +#[test] +fn party_names_are_marked_for_egress_and_accounting_selectors_are_not() { + let entry = json!({ + "position": 0, + "voucher_number": "JV-1", + "party": {"party_state": "bound", "catalog_name": "Bridge Nested Debtor WR4"}, + "presence": "present", + "book_key": "book-1", + "differences": [ + {"field": "party", "proposed": "Debtor As Written", "observed": "Bridge Nested Debtor WR4"}, + {"field": "amount", "proposed": "12.5", "observed": "11.5"}, + ], + }); + let marked = mark_presence_party_names(entry); + assert_eq!( + marked["party"]["catalog_name"][super::super::PARTY_NAME_MARKER], + "Bridge Nested Debtor WR4" + ); + assert_eq!( + marked["differences"][0]["proposed"][super::super::PARTY_NAME_MARKER], + "Debtor As Written" + ); + // An amount is not a party name and must not be wrapped. + assert_eq!(marked["differences"][1]["proposed"], "12.5"); + assert_eq!(marked["voucher_number"], "JV-1"); + let masked = redact_value(marked, Redaction::MaskParties); + let text = masked.to_string(); + assert!(!text.contains("Bridge Nested Debtor WR4"), "{text}"); + assert!(text.contains("12.5")); +} + +// --- one live-shaped cycle --------------------------------------------- + +fn company_xml() -> String { + format!("
1
{CAPTURED_GUID}120260401
") +} + +fn catalogue_xml() -> String { + let bytes = include_bytes!( + "../crates/bridge-tally-protocol/tests/fixtures/agent/native-ledger-catalogue.utf16le.xml" + ); + let words = bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(); + String::from_utf16(&words).expect("captured native catalogue") +} + +/// Two vouchers already in the book, one of them posted short of its source. +fn window_xml() -> String { + format!( + concat!( + "
1
", + "20260901JV-1", + "Journal{guid}-00000001", + "112", + "Bridge Nested Debtor WR4", + "NoNo", + "Bridge Nested Debtor WR4", + "Yes-12.50", + "WR2 Sales", + "No12.50", + "20260902JV-2", + "Journal{guid}-00000002", + "213", + "Café Naïve Traders", + "NoNo", + "Café Naïve Traders", + "Yes-7.00", + "WR2 Sales", + "No7.00", + "
" + ), + guid = CAPTURED_GUID + ) +} + +fn presence_plans() -> Vec { + let company = company_xml(); + let status = "TallyPrime Server is Running".to_string(); + let catalogue = catalogue_xml(); + let window = window_xml(); + vec![ + company.clone(), + status.clone(), + company.clone(), + status.clone(), + company.clone(), + catalogue.clone(), + status.clone(), + catalogue, + status.clone(), + company.clone(), + company.clone(), + window.clone(), + status.clone(), + window, + status, + company, + ] + .into_iter() + .enumerate() + .map(|(index, body)| { + if matches!(index, 1 | 3 | 6 | 8 | 12 | 14) { + ScenarioPlan::new(Fixture::ProductStatus( + tally_protocol_simulator::ProductStatus::TallyPrime, + )) + .with_framing(ResponseFraming::ContentLength) + } else { + ScenarioPlan::new(Fixture::SyntheticXml(body)) + .with_encoding(WireEncoding::Utf16Le) + .with_framing(ResponseFraming::ContentLength) + } + }) + .collect() +} + +#[tokio::test] +async fn a_live_shaped_cycle_separates_present_undecided_and_absent() { + let simulator = SequenceSimulator::spawn(presence_plans()).expect("simulator"); + let directory = tempfile::tempdir().expect("directory"); + let server = Server::new(Settings { + endpoint: TallyEndpointConfig { + host: simulator.address().ip().to_string(), + port: simulator.address().port(), + }, + data_dir: directory.path().to_path_buf(), + max_rows: 500, + max_bytes: 200_000, + redaction: Redaction::None, + import_enabled: false, + writes_enabled: false, + }); + let response = server + .call_tool( + "voucher_presence", + json!({ + "company_guid": CAPTURED_GUID, + "from": "20260901", + "to": "20260930", + "numbering": [{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers": [ + // Already in the book, and the book agrees. + proposal("JV-1", "Bridge Nested Debtor WR4", "12.50"), + // Already in the book, posted short by 0.50. + proposal("JV-2", "Café Naïve Traders", "7.50"), + // Not in the book, and nothing resembles it. + proposal("JV-9", "नमस्ते ट्रेडर्स", "99.00"), + ], + }), + ) + .await; + assert_eq!(response["isError"], false, "{response}"); + let result = &response["structuredContent"]["result"]; + assert_eq!(result["profile"], "agent_voucher_presence_v1"); + assert_eq!(result["window"]["from"], "20260901"); + assert_eq!(result["window"]["to"], "20260930"); + assert_eq!(result["totals"]["requested"], 3); + assert_eq!(result["totals"]["present"], 2); + assert_eq!(result["totals"]["absent"], 1); + assert_eq!(result["totals"]["possibly_present"], 0); + + let vouchers = result["vouchers"].as_array().expect("vouchers"); + assert_eq!(vouchers[0]["presence"], "present"); + assert_eq!(vouchers[0]["basis"], "manual_voucher_number"); + assert_eq!(vouchers[0]["book_key"], format!("{CAPTURED_GUID}-00000001")); + assert!(vouchers[0]["differences"] + .as_array() + .expect("differences") + .is_empty()); + + // The number identified it, so the date and amount the book disagrees on + // are findings about the book, not evidence against the match. + assert_eq!(vouchers[1]["presence"], "present"); + let differences = vouchers[1]["differences"].as_array().expect("differences"); + assert_eq!(differences.len(), 2); + assert_eq!(differences[0]["field"], "date"); + assert_eq!(differences[0]["proposed"], "20260901"); + assert_eq!(differences[0]["observed"], "20260902"); + assert_eq!(differences[1]["field"], "amount"); + assert_eq!(differences[1]["proposed"], "7.5"); + assert_eq!(differences[1]["observed"], "7"); + + assert_eq!(vouchers[2]["presence"], "absent"); + assert!(vouchers[2].get("book_key").is_none()); + assert_eq!(vouchers[2]["party"]["catalog_name"], "नमस्ते ट्रेडर्स"); + + // The book half of the report is computed, not asked for. + assert_eq!(result["book"]["window_voucher_count"], 2); + assert_eq!(result["book"]["remote_id_observed"], false); + assert_eq!(result["book"]["duplicate_number_group_count"], 0); + assert_eq!(result["book"]["unmatched_book_vouchers"], 0); + assert_eq!( + response["structuredContent"]["evidence"]["state"], + "complete" + ); + let observed = simulator.finish().expect("requests"); + assert_eq!(observed.len(), 16); +} diff --git a/src-tauri/src/agent_tests.rs b/src-tauri/src/agent_tests.rs index 4383d78e..a65215e8 100644 --- a/src-tauri/src/agent_tests.rs +++ b/src-tauri/src/agent_tests.rs @@ -304,10 +304,18 @@ fn mask_parties_walks_every_tool_sample_response_without_leaking_party_names() { "trial_balance", json!({"ledgers":[{"ledger":party_name("Entry Ledger")}]}), ), + ( + "voucher_presence", + json!({"vouchers":[super::presence::mark_presence_party_names(json!({ + "party":{"party_state":"bound","catalog_name":"Customer One"}, + "presence":"present", + "differences":[{"field":"party","proposed":"Customer One","observed":"Supplier Two"}], + }))]}), + ), ("read_evidence", json!({"records":[]})), ("egress_log", json!({"records":[]})), ]); - assert_eq!(samples.len(), 14); + assert_eq!(samples.len(), 15); for (tool, sample) in samples { let redacted = redact_value(sample, Redaction::MaskParties); assert_no_known_party_name(&redacted, &known_parties, tool); diff --git a/tools/bridge-tally-compatibility/src/lib.rs b/tools/bridge-tally-compatibility/src/lib.rs index 61dc5ad1..22f2758e 100644 --- a/tools/bridge-tally-compatibility/src/lib.rs +++ b/tools/bridge-tally-compatibility/src/lib.rs @@ -38,7 +38,17 @@ pub const RESERVED_SURFACE_FILES: usize = 15; /// digest unchanged and let existing evidence attest behaviour it never /// covered. This is the deliberate decision the paragraph above requires, and /// it is one file for one named reason — not headroom. -pub const MAX_SURFACE_FILES: usize = 211; +/// +/// Raised again from 211 to 213 to admit +/// `src-tauri/crates/bridge-tally-core/src/book_presence.rs` and +/// `src-tauri/src/agent_presence.rs`. Together they decide whether a proposed +/// voucher is reported as already in the book. Tally has no idempotency +/// (`TALLY_PROTOCOL_REFERENCE.md` §9.3), so an edit confined to either file +/// could turn a `present` into an `absent` — duplicating a filed invoice — or +/// the reverse, dropping one silently, while the surface digest and the +/// evidence attesting the reads beneath both stayed unchanged. Two files for +/// one named reason, one per surface; still not headroom. +pub const MAX_SURFACE_FILES: usize = 213; pub const MAX_OPERATIONS: usize = 16; pub const MAX_CLAIMS: usize = 128; pub const MAX_KEYS: usize = 32; From b7666191b9a6c120d9903b7761dedc74fa58fcc4 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:21:02 +0530 Subject: [PATCH 20/91] Bound the completeness claim to what the window read actually proves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two measurements from the master-binding session, and one thing ADR 0017 claimed more strongly than the code earns. The completeness guarantee is exactly as strong as the window read. Three failure paths are closed — a failed read never becomes a window, a short or malformed body fails the strict parse, and the paired read refuses a pair that disagrees. A well-formed response that is silently short is not closed, and a deterministic short answer agrees with itself across the pair, so pairing does not catch it. Named as the open residual, with what would close it. Withholding `absent` on an undistinguishable party name family was reasoned from the contract; it is now backed by measurement — an alphabetically capped slice of such a family omitted the right master about a third of the time over 470 real ledger names. Also recorded: the identifier rule has zero live coverage (0 of 470 names yield a numeric identifier). It is load bearing for master_binding's own consumers and deliberately not load bearing here, because a party binding can never produce `present` — only widen the resemblance net. A wrong bind costs a candidate and risks a visible, deletable duplicate; it cannot cause a silent suppression. Docs only. No pinned file changed; gate re-run and passing. Co-Authored-By: Claude Opus 5 --- docs/adr/0017-voucher-presence-authority.md | 47 +++++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index f1d235f8..abe81da9 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -82,15 +82,28 @@ Two binding outcomes withhold `Absent` outright: `NoDiscriminatingCandidate` In both, names that might have matched were never compared, and reporting "absent" off an incomplete comparison is the failure this ADR exists to prevent. +That withholding is not a precaution reasoned from the contract alone. Measured +over 470 ledger names from sixteen loaded synthetic companies and 2,257 +mutation cases: where binding lists candidates the right master is present in +403 of 403 rows, and where a source name reaches a family it cannot distinguish +an alphabetically capped slice of that family **omitted the right master about +a third of the time** — which is why the family is counted and not listed. On a +book with systematic party naming `NoDiscriminatingCandidate` is expected to be +common, and a third of the `Absent` verdicts it would otherwise license would +have been wrong. + ### 2. A window is a *claim about a window*, and it must be complete `BookWindow::observed` is a boundary parse. It refuses, rather than degrades, on: -- **a read that was not complete** — `WindowIncomplete`. A window too dense to - read, or one whose emptiness was only partially corroborated, is not "no - match found". This is the single most dangerous confusion available here and - it is a typed error, not a flag a caller may overlook; +- **a read that was not complete** — `WindowIncomplete`. A window whose + emptiness was only partially corroborated is not "no match found". This is + the single most dangerous confusion available here, so it is a typed error + rather than a flag a caller may overlook. A window that could not be read at + all never reaches this constructor: the read fails, and the tool fails with + it. What this does **not** cover is named in the Consequences — a response + Tally answers short without saying so; - a window that does not **cover** every proposed date — `WindowDoesNotCover`. A voucher outside the window is invisible, so a verdict over it would be fiction; @@ -284,6 +297,32 @@ human-approved batch — this ADR does not move. - The window is read in full before any comparison; `vouchers`' own pagination bounds output, not Tally's work. A window past `MAX_WINDOW_VOUCHERS` is refused with a narrow-the-range error rather than silently truncated. +- **The completeness guarantee is exactly as strong as the window read, and no + stronger.** Three ways a window read can go wrong are closed: a transport or + source-limit failure never produces a window because the read itself fails; a + malformed or short body fails the strict parse; and the paired read refuses a + pair whose two responses differ. The case that remains open is a + **well-formed response that is silently short** — Tally answering a dense + window with fewer vouchers than it holds and saying nothing. No layer beneath + this contract detects that, and a deterministic short answer agrees with + itself across the pair, so pairing does not catch it either. `BookWindow` + therefore inherits the `vouchers` profile's own qualification, which states + that dense windows are unqualified. Closing it needs a source-side control + total — a count the window read asserts about itself — and that is a separate + read contract with its own live evidence. Until then, prefer several narrow + windows to one dense one, and read `Absent` as scoped to a window that was + read narrow enough to trust. +- **The identifier rule that binds a party across spellings has no live + coverage.** Measured against sixteen loaded synthetic companies, zero of 470 + real ledger names yield a numeric identifier and exactly one yields a code + identifier, so that rule is qualified by fabricated data alone. It is load + bearing for `master_binding`'s own consumers; it is deliberately **not** load + bearing here, because a party binding can never produce `Present` — it only + selects which names the resemblance rules compare, which widens the net. A + wrong bind can therefore cost a `SamePartyAmount` candidate and turn a + `PossiblyPresent` into an `Absent` — a visible, deletable duplicate — and can + never turn an `Absent` into a `Present`, which is the silent direction. That + is the asymmetry of §7 holding under a rule that is not yet proven. - Presence is pure computation over already-observed data, so P1's live-evidence requirement is satisfied upstream by the two reads that produce its input. Its own tests are fabricated from a placeholder alphabet: they establish the From f5554f9a20824ff21e0d2d3e7f36fad63a9b8c38 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:42:06 +0530 Subject: [PATCH 21/91] Correct the idempotency premise this contract was argued from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brief, and this ADR's opening line, cited TALLY_PROTOCOL_REFERENCE.md 9.3 as a flat "Tally has no idempotency". That heading is narrower than it reads. IMPLEMENTATION_GUIDE.md 3.3a (VERIFIED 2026-07-30) records the exception: a client-supplied REMOTEID makes Create an upsert — CREATED=0/ALTERED=1, one voucher, not two — and the reference's own 9.8 scope clarification already carries that result and links to the guide. So the reference is not stale so much as 9.3 states the narrow case without its qualifier or a pointer. Fixing 9.3 itself needs its own PR: that file is pinned in the compatibility surface. None of this rescues the contract — both facts are about a voucher the client keyed, and a hand-keyed voucher has no client REMOTEID to dedupe against — but three claims here were wrong or unfounded: The REMOTEID key row asserted "absent from every hand-keyed voucher" as fact. Tally assigns its own REMOTEID where the client supplies none, so whether a UI-keyed voucher carries one is untested in either direction. Assuming it does not is as unfounded as assuming it does. The RemoteId basis was described as matching the key Bridge wrote. 3.3a verified the client key is NOT readable back — Tally overwrites the attribute with its own value — so a caller supplying its write key would match nothing and be told absent, correctly and uselessly. The basis means a Tally-assigned value the caller has previously read back. The error posture leaned on "a duplicate Bridge created carries Bridge's REMOTEID and is therefore deletable". Correction works by re-import under the same key, not by reading it back. The upsert also narrows the residual: a false absent on a Bridge-imported voucher creates nothing, so the duplicate risk is confined to hand-keyed vouchers. And the asymmetry now names its one external dependency — a writer key that collides across two business events converts a visible duplicate into a SILENT OVERWRITE, which lands on the same side as a false present. Also: the embedded-identifier rule is bimodal, not uncovered. Zero of 470 across synthetic companies and zero of 105 on one real book, but 91 of 214 on another whose operator embeds a contact number in each customer ledger name. A bonus signal a binder must work without, and decisive where it fires. And a test for a reachable state a peer flagged: master_binding now spends its aggregate candidate budget inside bind(), so a candidate list can arrive empty with candidates_truncated set for a reason unrelated to the party's own name. That already withheld absent; it is now pinned by test rather than by reasoning, with the untruncated contrast beside it. Docs and tests only. No pinned file changed; gate re-run and passing. Co-Authored-By: Claude Opus 5 --- docs/adr/0017-voucher-presence-authority.md | 76 ++++++++++++++----- .../src/book_presence_tests.rs | 54 +++++++++++++ 2 files changed, 111 insertions(+), 19 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index abe81da9..8c8d4be5 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -11,20 +11,32 @@ rejected without separate evidence. ## Context -**Tally has no idempotency.** Re-sending an identical voucher payload with the -same `VOUCHERNUMBER` creates a second voucher — verified, and recorded in -[`TALLY_PROTOCOL_REFERENCE.md` §9.3](../tally/TALLY_PROTOCOL_REFERENCE.md). -Nothing in the protocol dedupes on the client's behalf. Duplicated invoices -inside a filed GST period are a return problem, not a cosmetic one. +**Tally dedupes on one key and no other.** Re-sending an identical voucher +payload with the same `VOUCHERNUMBER` creates a second voucher — verified, and +recorded in [`TALLY_PROTOCOL_REFERENCE.md` +§9.3](../tally/TALLY_PROTOCOL_REFERENCE.md). A *client-supplied* `REMOTEID` is +the exception and the only one: +[`IMPLEMENTATION_GUIDE.md` +§3.3a](../tally/IMPLEMENTATION_GUIDE.md#33a-remoteid-is-the-idempotency-key--supersedes-34s-conclusion) +verified that re-importing the same payload under the same client `REMOTEID` +**upserts** — `CREATED=0, ALTERED=1`, one voucher, not two — and §9.8's scope +clarification records the same result on the licensed Journal path. + +Read §9.3's heading alone and you get "no idempotency", flat; that heading is +narrower than it reads and does not point at the exception. **Neither fact +rescues this contract, because both are about a voucher the *client* keyed.** +A voucher an operator typed into Tally by hand carries no client `REMOTEID` to +dedupe against, and duplicated invoices inside a filed GST period are a return +problem, not a cosmetic one. So before any generated batch can be imported, one question has to be answered and Bridge cannot answer it: -- A dealership's August sales: twenty invoices in the source report, **fifteen +- One engagement's month of sales: twenty invoices in the source report, **fifteen already keyed in by hand.** That was discovered only because the operator happened to send a Day Book screenshot. Without it the run would have posted twenty and duplicated fifteen. -- A trading firm's August sales: **forty-nine vouchers generated, validated, +- Another engagement's month of sales: **forty-nine vouchers generated, validated, arithmetic-checked, and un-importable at the end of the day**, waiting for a Day Book to arrive by hand the next morning. @@ -45,7 +57,7 @@ the contract that says what a comparison is allowed to conclude. | Candidate key | Where it holds | Where it fails | | --- | --- | --- | -| `REMOTEID` | Vouchers Bridge imported. Reliable. | Absent from every hand-keyed voucher — which is both blocked engagements. | +| `REMOTEID` | A voucher whose Tally-assigned value the caller has already observed. | The **client** key is not readable back at all: §3.3a verified that Tally overwrites the attribute with its own value, so a key Bridge wrote can never be matched against a later read. Whether a voucher keyed by hand in the Tally UI carries a Tally-assigned value is **untested in either direction** — assuming it does not would be as unfounded as assuming it does. | | `VOUCHERNUMBER` | Voucher types numbered **Manual**. One book preserved a long alphanumeric invoice series verbatim, another a plain three-digit bill number. | Under **Automatic** numbering Tally *discards* the supplied number (§9.8), so a number-based key is silently ineffective. And a book that does not set `PREVENTDUPLICATES` can hold the same number twice — one did, twenty-five times. | | date + party + amount | Needs neither of the above. | Collides. In one month of real data `141,600`, `177,000` and `16,992` each recurred across *unrelated* parties. | @@ -163,7 +175,11 @@ variant that can carry one. Two bases, and nothing else: - **`RemoteId`** — the proposal and exactly one book voucher carry the same - `REMOTEID`, and no other proposal carries it. + `REMOTEID`, and no other proposal carries it. Note carefully what a caller + may put there: **not** the client key it wrote on a previous import, which + §3.3a verified is overwritten and unreadable, but a Tally-assigned value it + has previously read back. A caller that supplies its own write key here will + match nothing and be told `absent` — correctly, and uselessly. - **`ManualVoucherNumber`** — the voucher type is declared `Manual`, and the (voucher type, normalized number) pair selects **exactly one book voucher and exactly one proposal**. Uniqueness on both sides is ADR 0016's rule 2, and it @@ -213,11 +229,17 @@ severity: - A false `Present` silently drops an invoice. Nothing records it. It is not in Tally, not in the return, not in Bridge, and not in any exceptions report. There is no artifact to find later. -- A false `Absent` creates a duplicate. Tally's own `Duplicate Voucher No.` - exceptions report surfaces it, and because Bridge wrote it, it carries - Bridge's `REMOTEID` — which is the key for the **only** correction path Tally - offers, since vouchers cannot be modified and deletion is by `REMOTEID` - (§9.7). A duplicate Bridge created is a duplicate Bridge can delete. +- A false `Absent` on a voucher **Bridge previously imported** creates nothing + at all: the same client `REMOTEID` upserts (§3.3a). The duplicate risk is + confined to vouchers an operator keyed by hand — which is the real residual, + and was both blocked engagements, but it is a smaller set than "everything". +- A false `Absent` on a hand-keyed voucher does create a duplicate, and that + duplicate is **visible and correctable**: Tally's own `Duplicate Voucher No.` + exceptions report surfaces it, and re-importing under the same client + `REMOTEID` overwrites the earlier row (§3.3a's correction path), which is the + only correction Tally offers since vouchers cannot be modified (§9.7). Note + the mechanism precisely — correction works by *re-import*, not by reading the + key back, because §3.3a verified the client key is not readable at all. **Therefore the bar for `Present` is set higher than the bar for `Absent`, and both are set higher than a resemblance.** `Present` requires identity; @@ -225,6 +247,17 @@ both are set higher than a resemblance.** `Present` requires identity; direction lands in `PossiblyPresent`, which authorises nothing and is handed to a person. +**This asymmetry has one dependency, and it is outside this contract.** It +holds only while the import writer derives a `REMOTEID` that is stable for a +business event and distinct between different ones. A key that collides across +two distinct events — a row ordinal within a re-downloaded window, say — turns +`REMOTEID`'s upsert from a safety property into a **silent overwrite of a +different voucher**, which lands on the same side of the ledger as a false +`Present`: no duplicate to see, no exception raised, nothing to find later. A +consumer acting on `Absent` inherits that risk from the writer, not from this +report. Any key proposed for that writer should be tested against both the +re-download case and the overlapping-window case before it is trusted. + The cost of this posture is operator review time. That is the intended cost: the middle is where a human is genuinely faster than any rule, and the alternative to reviewing it is an invisible omission or an invisible duplicate. @@ -312,11 +345,16 @@ human-approved batch — this ADR does not move. read contract with its own live evidence. Until then, prefer several narrow windows to one dense one, and read `Absent` as scoped to a window that was read narrow enough to trust. -- **The identifier rule that binds a party across spellings has no live - coverage.** Measured against sixteen loaded synthetic companies, zero of 470 - real ledger names yield a numeric identifier and exactly one yields a code - identifier, so that rule is qualified by fabricated data alone. It is load - bearing for `master_binding`'s own consumers; it is deliberately **not** load +- **The identifier rule that binds a party across spellings is bimodal, not + general.** Measured across three catalogs: zero of 470 names across sixteen + loaded synthetic companies, zero of 105 on one real book, and **91 of 214 — + about two in five — on another real book** whose operator embeds a contact + number in each customer's ledger name. So the rule has near-total coverage or + none at all depending on one operator's naming habit, and it is a bonus + signal a binder must work without rather than a key it may rely on. It is + decisive where it does fire: on that third book it resolved a customer that + the three closest name matches all got wrong. It is load bearing for + `master_binding`'s own consumers; it is deliberately **not** load bearing here, because a party binding can never produce `Present` — it only selects which names the resemblance rules compare, which widens the net. A wrong bind can therefore cost a `SamePartyAmount` candidate and turn a diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index d6950cdb..98965038 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1159,3 +1159,57 @@ fn every_undecided_reason_carries_a_distinct_stable_code() { .collect::>(); assert_eq!(codes.len(), 8); } + +// --- the aggregate candidate budget is a second source of "incomplete" ------ + +/// `master_binding` spends an aggregate candidate-byte budget in entity order +/// while the report is built, so an entity's candidate list can arrive **empty +/// with `candidates_truncated`** for a reason that has nothing to do with its +/// own name — pressure from earlier entities in the same report. That is a new +/// source of a signal this contract already acts on, and the danger is reading +/// "no candidates" as "nothing in this book resembles this party". +#[test] +fn an_aggregately_truncated_candidate_list_withholds_absent() { + let binding = master_binding::EntityBinding { + position: 0, + source_name: "Delta Trading".to_string(), + status: BindingStatus::Ambiguous(master_binding::Unresolved { + reason: UnboundReason::NearMiss, + unresolved_identity: Vec::new(), + // Empty, yet seven candidates were found before the budget ran out. + candidates: Vec::new(), + candidate_count: 7, + candidates_truncated: true, + }), + }; + let resolution = resolution_of(&binding); + assert!( + resolution.compare_keys.is_empty(), + "no name survived to be compared" + ); + assert!( + resolution.incomplete, + "names that were never compared cannot license an absence" + ); +} + +/// The contrast that keeps the rule honest: a party with no candidates and no +/// truncation genuinely has nothing resembling it in the catalog, so no posted +/// voucher can be carrying it and `Absent` stays available. +#[test] +fn an_untruncated_empty_candidate_list_still_permits_absent() { + let binding = master_binding::EntityBinding { + position: 0, + source_name: "Zulu Enterprises".to_string(), + status: BindingStatus::Unmatched(master_binding::Unresolved { + reason: UnboundReason::NoCandidate, + unresolved_identity: Vec::new(), + candidates: Vec::new(), + candidate_count: 0, + candidates_truncated: false, + }), + }; + let resolution = resolution_of(&binding); + assert!(resolution.compare_keys.is_empty()); + assert!(!resolution.incomplete); +} From 8c2d884fa699f2823d5a97ea4a2a10bea33e9424 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:46:20 +0530 Subject: [PATCH 22/91] Record that an empty candidate list is a recurring cross-surface trap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0017 justified withholding `absent` on an undiscriminable party family with a measurement. A sibling surface consuming the same `master_binding` state shows the trap is not hypothetical: it rendered such a family as "0 possible ledgers are listed first" — a count of nothing, presented to an operator as a result. Verified in that branch's own regression test rather than taken on report. Recorded here because the rule generalises past this contract: an empty candidate list means "nothing survived to be shown", never "nothing exists", and every consumer of the crate has to decide which of those it is reporting. This is the same defect class as `a-status-field-does-not-neutralize-the-value-beside-it` — a refusal whose neighbouring value reads as an answer. Docs only. Gate re-run and passing. Co-Authored-By: Claude Opus 5 --- docs/adr/0017-voucher-presence-authority.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 8c8d4be5..e9beccc6 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -104,6 +104,14 @@ book with systematic party naming `NoDiscriminatingCandidate` is expected to be common, and a third of the `Absent` verdicts it would otherwise license would have been wrong. +The trap this guards is not hypothetical, and it is not confined to this +contract. A sibling surface consuming the same state rendered an +undiscriminable family as "0 possible ledgers are listed first" — a count of +nothing, presented to an operator as a result. An empty candidate list means +"nothing survived to be shown", never "nothing exists"; every consumer of +`master_binding` has to decide which of those it is reporting, and the two +readings are one word apart in the output. + ### 2. A window is a *claim about a window*, and it must be complete `BookWindow::observed` is a boundary parse. It refuses, rather than degrades, From d1b63c5a4988aaf1f5512ea5079b4569cd0d4b39 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 01:53:41 +0530 Subject: [PATCH 23/91] Name what a caller may do about a difference, because the obvious move duplicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0017 reported a disagreement between a source and the book without saying what can be done about it. That is the same omission a sibling lane just fixed in ADR 0016 — a retained value whose channel is unnamed, where the obvious wrong choice fails silently — and here the obvious wrong choice is worse than silent. Verified in the reference rather than assumed. On the observed instance 9.7 records that voucher Alter returns CREATED=1, ALTERED=0 and makes a duplicate with the target untouched, tested against four different keys and duplicating on all four, and 9.6 records Cancel doing the same. Only Delete works, keyed by REMOTEID. So a caller reading "amount differs" and reaching for an Alter would create exactly the duplicate this contract exists to prevent, and the counters would report success. The correction that does exist is re-import under the same client REMOTEID (3.3a), which reaches only vouchers Bridge wrote. For a hand-keyed voucher — the case that produced the finding, and the case this contract is for — Bridge holds no client key and the vouchers profile does not fetch the Tally-assigned one, so there is no programmatic correction path at all. The operator fixes it in Tally. 9.7's Alter and Cancel results carry their own unverified-SKU caveat. That widens the uncertainty; it does not narrow the advice. Docs only. Rebased onto eaf3dd7 with a fresh reseal; gate passing. Co-Authored-By: Claude Opus 5 --- docs/adr/0017-voucher-presence-authority.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index e9beccc6..940bbe33 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -229,6 +229,27 @@ voucher number still matched perfectly. Under this contract that invoice comes back `Present` with an amount difference — precisely the report the client needed and nobody had asked for. +**Say plainly what a caller may do about it, because the obvious move is a +trap.** A difference is a finding for a person, not a work item for code. On +the observed instance §9.7 verified that voucher `Alter` returns +`CREATED=1, ALTERED=0` and **makes a duplicate while leaving the target +untouched** — tested against four different keys, all four duplicating — and +that `Cancel` behaves the same way (§9.6). Only `Delete` works, keyed by +`REMOTEID`. So a caller that reads "amount differs" and reaches for an `Alter` +to correct it would create the very duplicate this whole contract exists to +prevent, and Tally's counters would report success. + +The correction that does exist is re-import under the same client `REMOTEID` +(§3.3a), and it reaches **only vouchers Bridge itself wrote**. For a +hand-keyed voucher — the case that produced the ₹36.13 finding, and the case +this contract is for — Bridge holds no client key and the `vouchers` profile +does not even fetch the Tally-assigned one, so there is **no programmatic +correction path at all**. The operator fixes it in Tally. A report that names a +disagreement it cannot act on must say so, or the next person writes the +`Alter`. (§9.7's Alter and Cancel results carry their own "unverified whether +this is SKU-specific" caveat; that widens the uncertainty, it does not narrow +the advice.) + ### 7. The error posture, stated The two errors are not symmetric, and the asymmetry is **detectability**, not From d78ac8d78c11cd565163c256b2cd125862d0b68a Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 02:00:44 +0530 Subject: [PATCH 24/91] Hold this contract's own candidate list to the rule it asked of its producer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overloaded empty-vector shape has now been got wrong twice in two surfaces. Asking the producer to fix theirs while carrying the same smell here would be advice rather than a standard. In this contract an empty candidate list is one fact, not three: it arises only when the party comparison could not run, and truncation only ever cuts a list that is otherwise full. That was true by construction and is now held by test across a run exercising a book-number collision, a date-party-amount resemblance, a withheld name family and a clean absence — asserting that empty implies PartyNotDecidable with a zero count and no truncation flag, and that a listed count below the true count always sets the flag. Tests only. Gate re-run and passing. Co-Authored-By: Claude Opus 5 --- .../src/book_presence_tests.rs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 98965038..4b01f0db 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1213,3 +1213,77 @@ fn an_untruncated_empty_candidate_list_still_permits_absent() { assert!(resolution.compare_keys.is_empty()); assert!(!resolution.incomplete); } + +/// The overloaded-empty-vector shape has now been got wrong twice in two +/// surfaces, so this contract's *own* output must not repeat it. Here an empty +/// candidate list is one fact and not three: it happens only when the party +/// comparison could not run, and truncation only ever cuts a list that is +/// otherwise full. Held by construction today; held by test from now on. +#[test] +fn an_empty_candidate_list_means_exactly_one_thing_in_this_contract() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118"), + BookRow::new("book-2", "20260812", "AA0118").party("Bravo Industries"), + BookRow::new("book-3", "20260819", "AA0130").party("Charlie Minerals"), + ]); + let mut names: Vec = (1..=30) + .map(|index| format!("Echo Party {index:03}")) + .collect(); + names.extend(LEDGERS.iter().map(|name| (*name).to_string())); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("catalog"); + let proposals = [ + // Collides on a number carried by two book vouchers. + ProposalRow::new(0, "20260812", "AA0118").build(), + // Resembles on date, party and amount. + ProposalRow::new(1, "20260812", "AA0777").build(), + // Party is an undistinguishable family: withheld, not absent. + ProposalRow::new(2, "20260812", "AA0778") + .party("Echo Party 0") + .rows(vec![["Echo Party 0", "-99.00"], ["Sales Account", "99.00"]]) + .build(), + // Nothing resembles it at all. + ProposalRow::new(3, "20260812", "AA0779") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-13.00"], + ["Sales Account", "13.00"], + ]) + .build(), + ]; + let report = run( + &window, + &catalog, + &numbering(NumberingMethod::Manual), + &proposals, + ); + + let mut seen_empty = 0; + for entry in report.vouchers() { + let Some(undecided) = entry.undecided() else { + continue; + }; + if undecided.candidates.is_empty() { + seen_empty += 1; + assert_eq!( + undecided.reason, + UndecidedReason::PartyNotDecidable, + "an empty candidate list may only mean the comparison did not run" + ); + assert!(!undecided.candidates_truncated); + assert_eq!(undecided.candidate_count, 0); + } else { + // A listed count and a true count that disagree must say so. + assert_eq!( + undecided.candidates_truncated, + undecided.candidates.len() < undecided.candidate_count + ); + } + } + assert_eq!(seen_empty, 1, "the withheld-family case must be exercised"); + // And the whole run still partitions. + let totals = report.totals(); + assert_eq!( + totals.present + totals.possibly_present + totals.absent, + totals.requested + ); +} From 925a00ff4c9bf62532b4c18e2a6933a5cfceccec Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 02:06:46 +0530 Subject: [PATCH 25/91] Name the destructive default where a consumer actually looks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0017 says a reported difference has no programmatic correction path and that Alter silently duplicates. The code said neither: zero mentions of the mechanism in book_presence.rs or agent_presence.rs. A sibling lane made the point that decided this — a warning belongs on the type, because that is where a consumer looks rather than in a document they may never open. It found the same gap in its own retained identity, whose unnamed default was inert. Mine is not: a caller reading "amount differs" and reaching for an Alter creates the duplicate this contract exists to prevent, with the counters reporting success. The Difference type now carries the whole chain — 9.7's four-key Alter result, 9.6 for Cancel, 3.3a's re-import correction reaching only vouchers Bridge wrote, and the conclusion that a hand-keyed voucher has no programmatic correction at all. The voucher_presence tool description carries the short form, because a model reading "amount differs" is exactly the caller that would reach for the Alter. Docs only, no behaviour change. Reseal run for the one pinned file; gate passing, 863 lib and 148 core green. Co-Authored-By: Claude Opus 5 --- docs/tally/compatibility/compatibility-surface.json | 2 +- .../crates/bridge-tally-core/src/book_presence.rs | 12 ++++++++++++ src-tauri/src/agent_catalog.rs | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 166a454c..aff1fc52 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "03a3ac71c87294c936076be03fa0dc31136ff914bc97e468a3fcc454e0ed91e1" + "sha256": "25e67ef0a38af10c24d147b7654ae67b9cb6bea091fb52f4c71e65b04acbb81b" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 15990a43..8c437318 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -557,6 +557,18 @@ pub enum DifferenceField { /// A field on which an identified voucher disagrees with its source. The match /// was decided by identity, so a difference is a finding about the book — not /// evidence against the match. +/// +/// **It is a finding for a person, and the obvious way to act on it in code is +/// destructive.** On the observed instance a voucher `Alter` returns +/// `CREATED=1, ALTERED=0` and makes a duplicate while leaving the target +/// untouched (`TALLY_PROTOCOL_REFERENCE.md` §9.7, four keys tested and all four +/// duplicating), and `Cancel` behaves the same way (§9.6). A caller that reads +/// "amount differs" and reaches for an `Alter` creates the duplicate this whole +/// contract exists to prevent, and Tally's counters report success. The only +/// correction that works is re-import under the same client `REMOTEID` +/// (`IMPLEMENTATION_GUIDE.md` §3.3a), which reaches only vouchers Bridge itself +/// wrote — so for the hand-keyed voucher this contract is built for there is no +/// programmatic correction path at all, and the operator fixes it in Tally. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct Difference { pub field: DifferenceField, diff --git a/src-tauri/src/agent_catalog.rs b/src-tauri/src/agent_catalog.rs index 59f85822..be6e99e0 100644 --- a/src-tauri/src/agent_catalog.rs +++ b/src-tauri/src/agent_catalog.rs @@ -193,7 +193,7 @@ pub(super) fn registered_tool_definitions(import_enabled: bool, writes_enabled: json!({"type":"object","additionalProperties":false,"required":["company_guid","from","to"],"properties":{"company_guid":{"type":"string","minLength":1},"from":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"},"to":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"},"voucher_type":{"type":"string","maxLength":agent_import::MAX_MASTER_NAME_CHARS},"ledger":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"},"offset":{"type":"integer","minimum":0,"default":0},"limit":{"type":"integer","minimum":1,"default":500}}}), ), "voucher_presence" => ( - "Answer which of 1\u{2013}500 proposed vouchers are already in the book, over one literal window. Tally has no idempotency, so re-sending a voucher creates a second one. `presence` is present, possibly_present or absent, and only `present` names a book voucher. Only identity decides: a shared REMOTEID, or a voucher number on a voucher type you declare `manual` \u{2014} unique on both sides, within an observed voucher type, and never onto a cancelled or optional voucher. Date, party and amount only ever produce candidates, with the rule that surfaced each and no ranking or score. Every voucher type a proposal names needs a declared numbering method; under `automatic` Tally discards the supplied number, so nothing can be decided from it. `absent` means absent from this window, so cover the dates the book could hold. Reads the full window before comparing; dense windows can fail source limits. Party names bind through the same rules as validate_masters. This never dispatches import XML to Tally.", + "Answer which of 1\u{2013}500 proposed vouchers are already in the book, over one literal window. Tally has no idempotency, so re-sending a voucher creates a second one. `presence` is present, possibly_present or absent, and only `present` names a book voucher. Only identity decides: a shared REMOTEID, or a voucher number on a voucher type you declare `manual` \u{2014} unique on both sides, within an observed voucher type, and never onto a cancelled or optional voucher. Date, party and amount only ever produce candidates, with the rule that surfaced each and no ranking or score. Every voucher type a proposal names needs a declared numbering method; under `automatic` Tally discards the supplied number, so nothing can be decided from it. `absent` means absent from this window, so cover the dates the book could hold. Reads the full window before comparing; dense windows can fail source limits. Party names bind through the same rules as validate_masters. A reported difference on a `present` voucher is a finding for a person, not a work item: correcting a voucher by Alter or Cancel silently creates a duplicate instead (\u{00a7}9.7), and no Bridge path can correct a voucher it did not write. This never dispatches import XML to Tally.", json!({"type":"object","additionalProperties":false,"required":["company_guid","from","to","numbering","vouchers"],"properties":{ "company_guid":{"type":"string","minLength":1}, "from":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"}, From f5e233f30f12105c36ae0089b4c6cfa94d15b217 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 02:41:20 +0530 Subject: [PATCH 26/91] Fix nine review findings, four of which could drop an invoice silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex reviewed #294 and I could not invalidate a single finding. The four that mattered all failed in the silent direction this contract exists to avoid. One book voucher could satisfy two proposals. Each identity basis enforced its own uniqueness and nothing stopped two proposals reaching the same voucher by different bases — one by REMOTEID, another by a manual number — so a consumer would exclude two source vouchers against one book row. Every claimant of a contested voucher is now demoted. A proposal's REMOTEID was discarded and could still be called absent. The adapter sets every observed voucher's id to None because the read profile does not fetch it, so a proposal carrying a REMOTEID had its strongest key silently skipped and could still be reported absent on the keys that happened to remain. The report-level remote_id_observed flag did not stop that — a status field does not neutralise the verdict printed beside it, which is the exact defect ADR 0017 cites elsewhere and had reproduced. A window now declares RemoteIdEvidence, and NotRead withholds absent structurally. The catalogue was never corroborated after the voucher read. A ledger renamed between the two reads would bind proposals to the old name while the rows carry the new one, removing the only resemblance. Now re-read and refused on drift, matching the selected-voucher read. A number match contradicted by a different REMOTEID settled anyway. Reported as a conflict now, per ADR 0016's identifier-versus-name rule. Five more, none silent: candidates dropped by the response cap were counted as book vouchers no proposal reached; the party diagnostic tested every entry ledger while reporting the party field, so a real disagreement went unreported; the two cross-input refusals ran after three Tally reads despite the stated pre-read guarantee; nested schema bounds and additionalProperties were advertised and not enforced; and the tool description still carried the flat no-idempotency claim the ADR had already corrected. Nine regression tests, one per finding plus the contrasts that keep each rule from drifting. 865 lib and 157 core green, clippy and fmt clean, reseal run and gate passing. Two findings are not addressed here because they are not code: no live evidence, and window completeness resting on cardinality this read cannot prove. Both are already stated as limits in ADR 0017 and both are answered on their threads. Co-Authored-By: Claude Opus 5 --- docs/adr/0017-voucher-presence-authority.md | 50 +++- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/book_presence.rs | 280 +++++++++++++----- .../src/book_presence_tests.rs | 263 +++++++++++++++- src-tauri/src/agent_catalog.rs | 2 +- src-tauri/src/agent_presence.rs | 156 ++++++++-- src-tauri/src/agent_presence_tests.rs | 171 ++++++++--- 7 files changed, 785 insertions(+), 141 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 940bbe33..9a23f884 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -117,6 +117,15 @@ readings are one word apart in the output. `BookWindow::observed` is a boundary parse. It refuses, rather than degrades, on: +- **a `REMOTEID` column that was never read.** A window declares + `RemoteIdEvidence::Observed` or `NotRead`, because "no voucher carried one" + and "the profile never fetched it" are different facts and only the first is + evidence. Where a proposal carries a `REMOTEID` and the window is `NotRead`, + that proposal's strongest key was never compared, so it **cannot be + `Absent`** — it becomes `RemoteIdEvidenceUnavailable`. A report-level marker + would not have done: a status field does not neutralise the per-voucher + verdict printed beside it, which is the defect this contract cites elsewhere + and had reproduced here. - **a read that was not complete** — `WindowIncomplete`. A window whose emptiness was only partially corroborated is not "no match found". This is the single most dangerous confusion available here, so it is a typed error @@ -194,6 +203,22 @@ Two bases, and nothing else: is what makes the twenty-five-duplicates book safe: those numbers select more than one voucher, so they decide nothing and surface as an ambiguity instead. +Uniqueness within a basis is not enough, and two further rules close what it +leaves open: + +- **One book voucher satisfies at most one proposal, across bases.** Each basis + enforced its own uniqueness while nothing stopped two proposals reaching the + *same* voucher by *different* bases — one by `REMOTEID`, another by a manual + number. A consumer would then exclude two source vouchers against one book + row and silently drop an invoice, which is the failure this contract exists + to prevent. Every claimant of a contested voucher is demoted to + `BookVoucherClaimedTwice`; choosing between them would be auto-resolution. +- **Two identity signals that disagree are reported, not ranked.** Where a + number matches uniquely but the two sides carry *different* `REMOTEID`s, the + result is `IdentityConflict` rather than a `Present` settled in the number's + favour — the same rule ADR 0016 applies when an identifier contradicts an + exact name. + Number comparison uses the same NFC / dash-and-quote / case / whitespace comparison key as master binding, so a long alphanumeric invoice number and its differently punctuated twin agree. @@ -332,6 +357,24 @@ human-approved batch — this ADR does not move. qualified ledger-catalogue and `vouchers` window reads, refuses to build a window from a partial read, and shapes the report through the same party-name marking and egress redaction as every other read result. +- **The verdict is built from two independently timed reads, so the catalogue + is corroborated after the window.** A ledger renamed between them would let a + proposal bind the old name while the rows carry the new one, removing the + only resemblance and manufacturing an `Absent`. The adapter re-reads the + catalogue and refuses on drift, the same paired-snapshot rule the + selected-voucher read already applies. +- **Every refusal that depends only on the arguments happens before any Tally + request** — including the two cross-input ones, a proposal dated outside the + window and a voucher type absent from the numbering declaration. The crate + enforces them again at its own boundary; the adapter check exists so a + request that was always going to be refused does not first spend a company + probe, a catalogue read and a full window read. +- **The published `inputSchema` is enforced to its leaves.** The shared + argument validator bounds only outer arrays and the crate's own limits are far + wider than this tool advertises, so nested names, numbers, identifiers and + amounts are bounded at the adapter and an undeclared nested property is + refused. A schema promising `additionalProperties: false` that then accepts + them is a claim the boundary does not keep. - Voucher numbers and voucher-type names fold through `master_binding::comparison_key` — the *same* key master names use, now an explicit crate-wide contract point owned by ADR 0016 rather than a private @@ -352,9 +395,10 @@ human-approved batch — this ADR does not move. - **`RemoteId` is contract-complete and not reachable from the shipped read.** `render_agent_vouchers` does not `FETCH` `REMOTEID`; only the AlterID change feed does. Adding it changes a qualified read profile and needs its own live - evidence, so it is not done here. Until then the report states - `remote_id_observed: false`, so an absence of remote-id matches can never be - read as evidence that none exist. Both motivating engagements were hand-keyed + evidence, so it is not done here. The shipped adapter therefore declares + `RemoteIdEvidence::NotRead`, which makes the gap structural rather than + advisory: a proposal that supplies a `REMOTEID` is withheld from `Absent` + instead of being judged on the keys that happen to remain. Both motivating engagements were hand-keyed and would not have had one regardless. - The window is read in full before any comparison; `vouchers`' own pagination bounds output, not Tally's work. A window past `MAX_WINDOW_VOUCHERS` is diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index aff1fc52..d9c2f861 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "25e67ef0a38af10c24d147b7654ae67b9cb6bea091fb52f4c71e65b04acbb81b" + "sha256": "98824b5c84b888ef1150e8d4b6e9e0b3d15ac9fe40a3ee00693d8620230bf0f4" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/agent_presence.rs", - "sha256": "b2315878f89e44baa33d02fc735e652bf232cca73e706a6d636ebc14e5a4800d" + "sha256": "d3fbe652cdeccc2fb0b4005d874f21a8c60820ee58e6e24e51860f89bad0c163" }, { "path": "src-tauri/src/agent_read_profiles.rs", diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 8c437318..d295dda6 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -125,6 +125,19 @@ impl PresenceError { } } +/// Whether the window's read gathered `REMOTEID` at all. A read profile that +/// does not fetch the field yields `NotRead`, which is a different fact from +/// "no voucher carried one" and must not be confused with it: a proposal whose +/// own `REMOTEID` was never compared cannot be reported `Absent`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RemoteIdEvidence { + /// The read fetched `REMOTEID`; an absent value means the voucher has none. + Observed, + /// The read did not fetch `REMOTEID`; absence means nothing at all. + NotRead, +} + /// How completely the window's source read observed its range. Only a complete /// read may become a `BookWindow`; the other value exists so a caller must /// state which it has rather than omit the question. @@ -355,6 +368,7 @@ impl ProposedVoucher { pub struct BookWindow { from: TallyDate, to: TallyDate, + remote_id_evidence: RemoteIdEvidence, vouchers: Vec, } @@ -363,6 +377,7 @@ impl BookWindow { from: &str, to: &str, read: WindowRead, + remote_id_evidence: RemoteIdEvidence, vouchers: Vec, ) -> Result { if read != WindowRead::Complete { @@ -385,7 +400,12 @@ impl BookWindow { return Err(PresenceError::WindowDuplicateVoucherKey); } } - Ok(Self { from, to, vouchers }) + Ok(Self { + from, + to, + remote_id_evidence, + vouchers, + }) } pub fn from(&self) -> &str { @@ -400,6 +420,10 @@ impl BookWindow { &self.vouchers } + pub fn remote_id_evidence(&self) -> RemoteIdEvidence { + self.remote_id_evidence + } + fn covers(&self, date: &str) -> bool { date >= self.from.as_str() && date <= self.to.as_str() } @@ -432,6 +456,13 @@ impl NumberingDeclaration { Ok(Self { methods }) } + /// Whether a voucher type has a declared method, without needing the + /// caller to reproduce this crate's comparison key. A consumer validating + /// its own arguments before performing a read uses this. + pub fn declares(&self, voucher_type: &str) -> bool { + self.methods.contains_key(&comparison_key(voucher_type)) + } + fn method(&self, type_key: &str) -> Option { self.methods.get(type_key).copied() } @@ -528,6 +559,18 @@ pub enum UndecidedReason { /// The party comparison could not be completed, so no rule that needs a /// party actually ran and `Absent` is not available. PartyNotDecidable, + /// Two proposals both resolved to the same book voucher, possibly by + /// different identity bases. One book voucher can satisfy at most one + /// proposal, so every claimant is demoted rather than one being chosen. + BookVoucherClaimedTwice, + /// A number matched uniquely while the two sides carried *different* + /// `REMOTEID`s. Two identity signals disagree, and a disagreement is + /// reported rather than settled in the number's favour. + IdentityConflict, + /// The proposal carries a `REMOTEID` the window never read, so the + /// strongest key available to this proposal was never compared. An + /// `Absent` here would rest on evidence that was not gathered. + RemoteIdEvidenceUnavailable, } impl UndecidedReason { @@ -542,6 +585,9 @@ impl UndecidedReason { Self::VoucherTypeNotObserved => "presence_voucher_type_not_observed", Self::ResemblesBookVoucher => "presence_resembles_book_voucher", Self::PartyNotDecidable => "presence_party_not_decidable", + Self::BookVoucherClaimedTwice => "presence_book_voucher_claimed_twice", + Self::IdentityConflict => "presence_identity_conflict", + Self::RemoteIdEvidenceUnavailable => "presence_remote_id_evidence_unavailable", } } } @@ -967,30 +1013,49 @@ pub fn assess(request: &PresenceRequest<'_>) -> PresenceReport { &proposal_remote_counts, &proposal_number_counts, ); - match &decided.status { - PresenceStatus::Present { book_key, .. } => { - if let Some(position) = window - .vouchers - .iter() - .position(|voucher| voucher.key() == book_key) - { - touched_book.insert(position); - } - } - PresenceStatus::PossiblyPresent(undecided) => { - for candidate in &undecided.candidates { - if let Some(position) = window - .vouchers - .iter() - .position(|voucher| voucher.key() == candidate.book_key) - { - touched_book.insert(position); - } - } + touched_book.extend(decided.touched); + vouchers.push(decided.presence); + } + + // One book voucher satisfies at most one proposal. Uniqueness was enforced + // within each identity basis; nothing yet stopped two proposals reaching + // the same voucher by *different* bases — one by `REMOTEID`, another by a + // manual number — and a consumer would then exclude two source vouchers + // against one book row, silently dropping an invoice. Every claimant is + // demoted; choosing between them would be the auto-resolution this whole + // contract refuses. + let mut claims: BTreeMap = BTreeMap::new(); + for entry in &vouchers { + if let Some(book_key) = entry.present_book_key() { + *claims.entry(book_key.to_string()).or_default() += 1; + } + } + let contested = claims + .into_iter() + .filter(|(_, count)| *count > 1) + .map(|(book_key, _)| book_key) + .collect::>(); + if !contested.is_empty() { + for entry in &mut vouchers { + let Some(book_key) = entry.present_book_key() else { + continue; + }; + if !contested.contains(book_key) { + continue; } - PresenceStatus::Absent => {} + let book_key = book_key.to_string(); + let rule = match &entry.status { + PresenceStatus::Present { + basis: PresenceBasis::RemoteId, + .. + } => CandidateRule::SharedRemoteId, + _ => CandidateRule::SharedVoucherNumber, + }; + entry.status = PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::BookVoucherClaimedTwice, + vec![PresenceCandidate { book_key, rule }], + )); } - vouchers.push(decided); } let observations = observe(window, &index, &proposed_type_keys, &touched_book); @@ -1002,6 +1067,15 @@ pub fn assess(request: &PresenceRequest<'_>) -> PresenceReport { } } +/// One proposal's verdict, plus every book voucher it reached *before* the +/// response candidate cap. The observations need the full set: a candidate +/// dropped by the cap was still resembled, and counting it as untouched would +/// report it as a voucher no proposal came near. +struct Decided { + presence: VoucherPresence, + touched: BTreeSet, +} + #[allow(clippy::too_many_arguments)] fn decide( proposal: &ProposedVoucher, @@ -1011,36 +1085,49 @@ fn decide( numbering: &NumberingDeclaration, proposal_remote_counts: &BTreeMap<&str, usize>, proposal_number_counts: &BTreeMap<(&str, &str), usize>, -) -> VoucherPresence { +) -> Decided { let method = numbering .method(&proposal.type_key) .expect("PresenceRequest refused an undeclared numbering method"); let type_observed = index.type_keys.contains(proposal.type_key.as_str()); - let shell = |status: PresenceStatus| VoucherPresence { - position: proposal.position, - voucher_number: proposal.voucher_number.clone(), - numbering_method: method, - voucher_type_observed: type_observed, - party: party.outcome.clone(), - status, + let shell = |status: PresenceStatus, touched: BTreeSet| Decided { + presence: VoucherPresence { + position: proposal.position, + voucher_number: proposal.voucher_number.clone(), + numbering_method: method, + voucher_type_observed: type_observed, + party: party.outcome.clone(), + status, + }, + touched, }; + // A proposal carrying a `REMOTEID` the window never fetched has had its + // strongest key silently skipped. That cannot license an absence. + let remote_id_unverifiable = + proposal.remote_id.is_some() && window.remote_id_evidence() == RemoteIdEvidence::NotRead; // Rule one: identity first. A REMOTEID is a key Bridge itself wrote. if let Some(remote_id) = proposal.remote_id.as_deref() { if let Some(matches) = index.by_remote_id.get(remote_id) { let unique_here = proposal_remote_counts.get(remote_id).copied() == Some(1); if matches.len() == 1 && unique_here { - return shell(settled( - proposal, - party, - &window.vouchers[matches[0]], - PresenceBasis::RemoteId, - )); + return shell( + settled( + proposal, + party, + &window.vouchers[matches[0]], + PresenceBasis::RemoteId, + ), + BTreeSet::from([matches[0]]), + ); } - return shell(PresenceStatus::PossiblyPresent(undecided( - UndecidedReason::RemoteIdCollision, - candidates_from(window, matches, CandidateRule::SharedRemoteId), - ))); + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::RemoteIdCollision, + candidates_from(window, matches, CandidateRule::SharedRemoteId), + )), + matches.iter().copied().collect(), + ); } } @@ -1073,25 +1160,60 @@ fn decide( .copied() .unwrap_or_default() > 1; + let touched = number_matches.iter().copied().collect::>(); if proposed_twice { - return shell(PresenceStatus::PossiblyPresent(undecided( - UndecidedReason::ProposalNumberCollision, - candidates_from(window, &number_matches, CandidateRule::SharedVoucherNumber), - ))); + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::ProposalNumberCollision, + candidates_from( + window, + &number_matches, + CandidateRule::SharedVoucherNumber, + ), + )), + touched, + ); } if number_matches.len() > 1 { - return shell(PresenceStatus::PossiblyPresent(undecided( - UndecidedReason::BookNumberCollision, - candidates_from(window, &number_matches, CandidateRule::SharedVoucherNumber), - ))); + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::BookNumberCollision, + candidates_from( + window, + &number_matches, + CandidateRule::SharedVoucherNumber, + ), + )), + touched, + ); } if number_matches.len() == 1 { - return shell(settled( - proposal, - party, - &window.vouchers[number_matches[0]], - PresenceBasis::ManualVoucherNumber, - )); + let matched = &window.vouchers[number_matches[0]]; + // Two identity signals that disagree are reported, never + // settled in the number's favour — the same rule ADR 0016 + // applies to an identifier contradicting an exact name. + let contradicted = + match (proposal.remote_id.as_deref(), matched.remote_id.as_deref()) { + (Some(proposed), Some(observed)) => proposed != observed, + _ => false, + }; + if contradicted { + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::IdentityConflict, + candidates_from( + window, + &number_matches, + CandidateRule::SharedVoucherNumber, + ), + )), + touched, + ); + } + return shell( + settled(proposal, party, matched, PresenceBasis::ManualVoucherNumber), + touched, + ); } } } @@ -1130,15 +1252,27 @@ fn decide( } if found.is_empty() { - // Nothing resembled it — but if the party comparison never ran to - // completion, that absence is not evidence. + // Nothing resembled it — but an absence is only evidence when every + // key this proposal carries was actually compared. + if remote_id_unverifiable { + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::RemoteIdEvidenceUnavailable, + Vec::new(), + )), + BTreeSet::new(), + ); + } if party.incomplete { - return shell(PresenceStatus::PossiblyPresent(undecided( - UndecidedReason::PartyNotDecidable, - Vec::new(), - ))); + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::PartyNotDecidable, + Vec::new(), + )), + BTreeSet::new(), + ); } - return shell(PresenceStatus::Absent); + return shell(PresenceStatus::Absent, BTreeSet::new()); } let reason = match ( @@ -1150,6 +1284,7 @@ fn decide( (false, true, false) => UndecidedReason::NumberNotDecisive, _ => UndecidedReason::ResemblesBookVoucher, }; + let touched = found.keys().copied().collect::>(); let mut candidates = found .into_iter() .map(|(position, rule)| PresenceCandidate { @@ -1163,9 +1298,10 @@ fn decide( .cmp(&right.rule.rank()) .then_with(|| left.book_key.cmp(&right.book_key)) }); - shell(PresenceStatus::PossiblyPresent(undecided( - reason, candidates, - ))) + shell( + PresenceStatus::PossiblyPresent(undecided(reason, candidates)), + touched, + ) } /// Turns an identity match into a status. A cancelled or optional voucher @@ -1220,12 +1356,20 @@ fn differences( // Only a bound party can disagree. An ambiguous one has no single name to // disagree with, and asserting a difference from a candidate would be the // same guess by another route. - if let PartyOutcome::Bound { catalog_name } = &party.outcome { - if !voucher.ledger_keys.contains(&comparison_key(catalog_name)) { + // The diagnostic compares against the *observed party field*, not against + // every ledger the voucher touches. Widening to all entry ledgers is right + // for finding a candidate and wrong for reporting a disagreement: a + // voucher whose party is one name while an entry names another would + // otherwise report no difference while serializing the other name as + // `observed`. A voucher with no party field has nothing to disagree with. + if let (PartyOutcome::Bound { catalog_name }, Some(observed)) = + (&party.outcome, voucher.party.as_deref()) + { + if comparison_key(observed) != comparison_key(catalog_name) { differences.push(Difference { field: DifferenceField::Party, proposed: Some(catalog_name.clone()), - observed: voucher.party.clone(), + observed: Some(observed.to_string()), }); } } diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 4b01f0db..582540a5 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -83,6 +83,13 @@ impl BookRow { self } + /// Sets PARTYLEDGERNAME alone, leaving the entry ledgers untouched, so a + /// voucher whose party field and entries name different ledgers can exist. + fn party_field(mut self, party: &'static str) -> Self { + self.party = Some(party); + self + } + fn cancelled(mut self) -> Self { self.cancelled = true; self @@ -179,6 +186,7 @@ fn window(rows: &[BookRow]) -> BookWindow { "20260801", "20260831", WindowRead::Complete, + RemoteIdEvidence::Observed, rows.iter().map(BookRow::build).collect(), ) .expect("window") @@ -211,8 +219,14 @@ fn reason(entry: &VoucherPresence) -> UndecidedReason { #[test] fn a_partial_read_can_never_become_a_window() { - let error = BookWindow::observed("20260801", "20260831", WindowRead::Partial, Vec::new()) - .expect_err("a partial read is not a window"); + let error = BookWindow::observed( + "20260801", + "20260831", + WindowRead::Partial, + RemoteIdEvidence::Observed, + Vec::new(), + ) + .expect_err("a partial read is not a window"); assert_eq!(error, PresenceError::WindowIncomplete); assert_eq!(error.safe_reason_code(), "presence_window_incomplete"); } @@ -235,8 +249,14 @@ fn an_empty_complete_window_is_legal_and_reports_everything_absent() { fn a_window_refuses_a_voucher_dated_outside_its_own_range() { let outside = BookRow::new("book-1", "20260901", "AA0118").build(); assert_eq!( - BookWindow::observed("20260801", "20260831", WindowRead::Complete, vec![outside]) - .expect_err("outside"), + BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + vec![outside] + ) + .expect_err("outside"), PresenceError::WindowVoucherOutsideRange ); } @@ -248,8 +268,14 @@ fn a_window_refuses_the_same_voucher_key_twice() { BookRow::new("book-1", "20260813", "AA0119").build(), ]; assert_eq!( - BookWindow::observed("20260801", "20260831", WindowRead::Complete, rows) - .expect_err("duplicate"), + BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + rows + ) + .expect_err("duplicate"), PresenceError::WindowDuplicateVoucherKey ); } @@ -257,8 +283,14 @@ fn a_window_refuses_the_same_voucher_key_twice() { #[test] fn a_window_refuses_an_inverted_range() { assert_eq!( - BookWindow::observed("20260831", "20260801", WindowRead::Complete, Vec::new()) - .expect_err("inverted"), + BookWindow::observed( + "20260831", + "20260801", + WindowRead::Complete, + RemoteIdEvidence::Observed, + Vec::new() + ) + .expect_err("inverted"), PresenceError::WindowRangeInvalid ); } @@ -1287,3 +1319,218 @@ fn an_empty_candidate_list_means_exactly_one_thing_in_this_contract() { totals.requested ); } + +// --- one book voucher satisfies at most one proposal -------------------- + +#[test] +fn two_proposals_reaching_one_book_voucher_are_both_demoted() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").remote_id("bridge-txn-1")]); + let proposals = [ + // Reaches book-1 by REMOTEID. + ProposalRow::new(0, "20260812", "AA9999") + .remote_id("bridge-txn-1") + .build(), + // Reaches the same voucher by its manual number. + ProposalRow::new(1, "20260812", "AA0118").build(), + ]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + // Neither may be excluded from an import: only one voucher exists. + assert_eq!(report.totals().present, 0); + for entry in report.vouchers() { + assert_eq!(reason(entry), UndecidedReason::BookVoucherClaimedTwice); + assert_eq!( + entry.undecided().expect("undecided").candidates[0].book_key, + "book-1" + ); + } +} + +#[test] +fn distinct_proposals_reaching_distinct_vouchers_both_stay_present() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118").remote_id("bridge-txn-1"), + BookRow::new("book-2", "20260813", "AA0119").party("Bravo Industries"), + ]); + let proposals = [ + ProposalRow::new(0, "20260812", "AA0118").build(), + ProposalRow::new(1, "20260813", "AA0119") + .party("Bravo Industries") + .build(), + ]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(report.totals().present, 2); +} + +// --- two identity signals that disagree --------------------------------- + +#[test] +fn a_number_match_contradicted_by_a_different_remote_id_does_not_settle() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").remote_id("tally-1")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .remote_id("tally-2") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::IdentityConflict); +} + +#[test] +fn a_number_match_agreeing_with_the_remote_id_still_settles() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").remote_id("tally-1")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .remote_id("tally-1") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + // The REMOTEID decides it first; either basis is an identity. + assert!(only(&report).present_book_key().is_some()); +} + +// --- a key that was never read is not a key that found nothing ---------- + +#[test] +fn a_remote_id_the_window_never_read_withholds_absent() { + let unread = BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::NotRead, + vec![BookRow::new("book-1", "20260819", "AA0130") + .party("Bravo Industries") + .build()], + ) + .expect("window"); + let proposals = [ProposalRow::new(0, "20260812", "AA0777") + .remote_id("tally-1") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-55.00"], + ["Sales Account", "55.00"], + ]) + .build()]; + let report = run( + &unread, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!( + !entry.is_absent(), + "the proposal's strongest key was never compared" + ); + assert_eq!(reason(entry), UndecidedReason::RemoteIdEvidenceUnavailable); +} + +#[test] +fn the_same_proposal_is_absent_when_the_window_did_read_remote_ids() { + let window = window(&[BookRow::new("book-1", "20260819", "AA0130").party("Bravo Industries")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0777") + .remote_id("tally-1") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-55.00"], + ["Sales Account", "55.00"], + ]) + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert!(only(&report).is_absent()); +} + +// --- the response cap must not distort the observations ----------------- + +#[test] +fn candidates_dropped_by_the_response_cap_still_count_as_reached() { + let rows: Vec = (1..=30) + .map(|index| { + BookRow::new( + Box::leak(format!("book-{index:02}").into_boxed_str()), + "20260812", + Box::leak(format!("BB{index:04}").into_boxed_str()), + ) + }) + .collect(); + let window = window(&rows); + let proposals = [ProposalRow::new(0, "20260812", "AA0777").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let undecided = only(&report).undecided().expect("undecided"); + assert_eq!(undecided.candidate_count, 30); + assert!(undecided.candidates_truncated); + assert_eq!(undecided.candidates.len(), MAX_CANDIDATES_PER_PROPOSAL); + // All thirty were reached; none may be reported as untouched merely + // because the response could not carry it. + assert_eq!(report.observations().unmatched_book_vouchers, 0); +} + +// --- the party diagnostic reads the party field ------------------------- + +#[test] +fn a_party_difference_compares_the_observed_party_field_not_every_ledger() { + // PARTYLEDGERNAME is Bravo while the entries still name Alpha. + let window = + window(&[BookRow::new("book-1", "20260812", "AA0118").party_field("Bravo Industries")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + let party = differences + .iter() + .find(|difference| difference.field == DifferenceField::Party) + .expect("the party field disagrees and must be reported"); + assert_eq!(party.proposed.as_deref(), Some("Alpha Traders")); + assert_eq!(party.observed.as_deref(), Some("Bravo Industries")); +} + +#[test] +fn a_voucher_with_no_party_field_has_nothing_to_disagree_with() { + let mut row = BookRow::new("book-1", "20260812", "AA0118"); + row.party = None; + let window = window(&[row]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + assert!(differences.is_empty()); +} diff --git a/src-tauri/src/agent_catalog.rs b/src-tauri/src/agent_catalog.rs index be6e99e0..5d5584e9 100644 --- a/src-tauri/src/agent_catalog.rs +++ b/src-tauri/src/agent_catalog.rs @@ -193,7 +193,7 @@ pub(super) fn registered_tool_definitions(import_enabled: bool, writes_enabled: json!({"type":"object","additionalProperties":false,"required":["company_guid","from","to"],"properties":{"company_guid":{"type":"string","minLength":1},"from":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"},"to":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"},"voucher_type":{"type":"string","maxLength":agent_import::MAX_MASTER_NAME_CHARS},"ledger":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"},"offset":{"type":"integer","minimum":0,"default":0},"limit":{"type":"integer","minimum":1,"default":500}}}), ), "voucher_presence" => ( - "Answer which of 1\u{2013}500 proposed vouchers are already in the book, over one literal window. Tally has no idempotency, so re-sending a voucher creates a second one. `presence` is present, possibly_present or absent, and only `present` names a book voucher. Only identity decides: a shared REMOTEID, or a voucher number on a voucher type you declare `manual` \u{2014} unique on both sides, within an observed voucher type, and never onto a cancelled or optional voucher. Date, party and amount only ever produce candidates, with the rule that surfaced each and no ranking or score. Every voucher type a proposal names needs a declared numbering method; under `automatic` Tally discards the supplied number, so nothing can be decided from it. `absent` means absent from this window, so cover the dates the book could hold. Reads the full window before comparing; dense windows can fail source limits. Party names bind through the same rules as validate_masters. A reported difference on a `present` voucher is a finding for a person, not a work item: correcting a voucher by Alter or Cancel silently creates a duplicate instead (\u{00a7}9.7), and no Bridge path can correct a voucher it did not write. This never dispatches import XML to Tally.", + "Answer which of 1\u{2013}500 proposed vouchers are already in the book, over one literal window. Tally dedupes on one key only: re-sending a voucher under the same VOUCHERNUMBER creates a second one, while a client-supplied REMOTEID upserts instead. A voucher keyed by hand carries no client REMOTEID, so it is the one at duplication risk. `presence` is present, possibly_present or absent, and only `present` names a book voucher. Only identity decides: a shared REMOTEID, or a voucher number on a voucher type you declare `manual` \u{2014} unique on both sides, within an observed voucher type, and never onto a cancelled or optional voucher. Date, party and amount only ever produce candidates, with the rule that surfaced each and no ranking or score. Every voucher type a proposal names needs a declared numbering method; under `automatic` Tally discards the supplied number, so nothing can be decided from it. `absent` means absent from this window, so cover the dates the book could hold. Reads the full window before comparing; dense windows can fail source limits. Party names bind through the same rules as validate_masters. A reported difference on a `present` voucher is a finding for a person, not a work item: correcting a voucher by Alter or Cancel silently creates a duplicate instead (\u{00a7}9.7), and no Bridge path can correct a voucher it did not write. This never dispatches import XML to Tally.", json!({"type":"object","additionalProperties":false,"required":["company_guid","from","to","numbering","vouchers"],"properties":{ "company_guid":{"type":"string","minLength":1}, "from":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"}, diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs index 7e432dca..fbfe568b 100644 --- a/src-tauri/src/agent_presence.rs +++ b/src-tauri/src/agent_presence.rs @@ -6,11 +6,12 @@ //! qualified reads that produce the evidence, the typed parse of the caller's //! proposals, and the response shape. use super::*; +use std::collections::BTreeSet; use bridge_tally_core::book_presence::{ self, BookVoucher, BookWindow, NumberingDeclaration, NumberingMethod, ObservedEntry, ObservedVoucher, PresenceError, PresenceReport, PresenceRequest, ProposedVoucher, - ProposedVoucherInput, WindowRead, + ProposedVoucherInput, RemoteIdEvidence, WindowRead, }; use bridge_tally_core::master_binding::{MasterCatalog, MasterClass}; @@ -21,6 +22,42 @@ pub(super) const MAX_PRESENCE_VOUCHERS: usize = 500; pub(super) const MAX_PRESENCE_VOUCHER_TYPES: usize = 50; /// Most ledger entries one proposed voucher may carry. pub(super) const MAX_PRESENCE_ENTRIES: usize = 200; +/// Longest accepted amount lexeme, matching the published schema. +const MAX_PRESENCE_AMOUNT_CHARS: usize = 64; + +/// The shared argument validator bounds only the outer arrays, and the core +/// crate's own limits are far wider than what this tool advertises. So every +/// nested string is bounded here against the published `inputSchema`, and an +/// unknown nested property is refused rather than ignored — a schema that +/// promises `additionalProperties: false` and then accepts them is a claim the +/// boundary does not keep. +fn nested_text( + object: &Value, + key: &str, + argument: &str, + max_chars: usize, +) -> Result, String> { + let Some(value) = object.get(key) else { + return Ok(None); + }; + let text = value + .as_str() + .ok_or_else(|| format!("argument_invalid:{argument}"))?; + if text.trim().is_empty() || text.chars().count() > max_chars { + return Err(format!("argument_invalid:{argument}")); + } + Ok(Some(text.to_string())) +} + +fn only_known_keys(object: &Value, known: &[&str], argument: &str) -> Result<(), String> { + let map = object + .as_object() + .ok_or_else(|| format!("argument_invalid:{argument}"))?; + if map.keys().any(|key| !known.contains(&key.as_str())) { + return Err(format!("argument_invalid:{argument}")); + } + Ok(()) +} impl Server { pub(super) async fn voucher_presence(&self, args: &Value) -> Result { @@ -34,6 +71,24 @@ impl Server { // proposal set should never cost a read. let numbering = parse_numbering(args)?; let proposals = parse_proposals(args)?; + // Both remaining cross-input refusals depend only on the arguments, so + // they are settled here rather than after three Tally reads. The crate + // enforces them again at its own boundary; this only stops a request + // that was always going to be refused from exercising the endpoint. + for proposal in &proposals { + if proposal.date() < from.as_str() || proposal.date() > to.as_str() { + return Err(PresenceError::WindowDoesNotCover + .safe_reason_code() + .to_string() + .into()); + } + if !numbering.declares(proposal.voucher_type()) { + return Err(PresenceError::NumberingMethodUndeclared + .safe_reason_code() + .to_string() + .into()); + } + } let (company, identity, accumulated) = self.verified_company(guid).await?; let mut accumulated = Some(accumulated); @@ -74,12 +129,42 @@ impl Server { } } + // The verdict is built from two independently timed observations, + // so the catalogue must still be the one the parties bound + // against. A ledger renamed between the reads would otherwise let + // a proposal bind an old name while the rows carry the new one, + // removing the only resemblance and manufacturing an `absent`. + // Same paired-snapshot rule the selected-voucher read applies. + let (corroboration, corroboration_evidence) = + self.read_ledger_catalogue(&identity, &company.name).await?; + accumulate(&mut accumulated, corroboration_evidence); + let before = catalogue + .iter() + .map(String::as_str) + .collect::>(); + let after = corroboration + .iter() + .map(String::as_str) + .collect::>(); + if before.len() != catalogue.len() + || after.len() != corroboration.len() + || before != after + { + return Err("ledger_snapshot_drifted".to_string().into()); + } + let observed = rows .iter() .map(book_voucher) .collect::, _>>() .map_err(presence_code)?; - let window = BookWindow::observed(&from, &to, read, observed).map_err(presence_code)?; + // The qualified `vouchers` profile does not FETCH REMOTEID, so an + // absent value here means "never read", not "the voucher has + // none". Declaring that keeps a proposal whose own REMOTEID was + // never compared out of `absent`. + let window = + BookWindow::observed(&from, &to, read, RemoteIdEvidence::NotRead, observed) + .map_err(presence_code)?; let request = PresenceRequest::new(&window, &catalog, &numbering, &proposals) .map_err(presence_code)?; let report = book_presence::assess(&request); @@ -156,17 +241,21 @@ fn parse_numbering(args: &Value) -> Result { let entries = declared .iter() .map(|entry| { - let voucher_type = entry - .get("voucher_type") - .and_then(Value::as_str) - .ok_or_else(|| "argument_invalid:numbering".to_string())?; + only_known_keys(entry, &["voucher_type", "numbering_method"], "numbering")?; + let voucher_type = nested_text( + entry, + "voucher_type", + "numbering", + agent_import::MAX_MASTER_NAME_CHARS, + )? + .ok_or_else(|| "argument_invalid:numbering".to_string())?; let method = match entry.get("numbering_method").and_then(Value::as_str) { Some("manual") => NumberingMethod::Manual, Some("automatic") => NumberingMethod::Automatic, Some("unknown") => NumberingMethod::Unknown, _ => return Err("argument_invalid:numbering".to_string()), }; - Ok((voucher_type.to_string(), method)) + Ok((voucher_type, method)) }) .collect::, String>>()?; NumberingDeclaration::new(entries).map_err(|error| error.safe_reason_code().to_string()) @@ -182,44 +271,61 @@ fn parse_proposals(args: &Value) -> Result, String> { } let mut parsed = Vec::with_capacity(proposed.len()); for (position, voucher) in proposed.iter().enumerate() { + only_known_keys( + voucher, + &[ + "date", + "voucher_type", + "voucher_number", + "remote_id", + "party", + "entries", + ], + "vouchers", + )?; let date = normalized_date( voucher .get("date") .and_then(Value::as_str) .ok_or_else(|| "argument_invalid:vouchers".to_string())?, )?; - let voucher_type = voucher - .get("voucher_type") - .and_then(Value::as_str) + let name_limit = agent_import::MAX_MASTER_NAME_CHARS; + let voucher_type = nested_text(voucher, "voucher_type", "vouchers", name_limit)? .ok_or_else(|| "argument_invalid:vouchers".to_string())?; + let voucher_number = nested_text(voucher, "voucher_number", "vouchers", name_limit)?; + let remote_id = nested_text(voucher, "remote_id", "vouchers", name_limit)?; + let party = nested_text(voucher, "party", "vouchers", name_limit)?; let rows = voucher .get("entries") .and_then(Value::as_array) .filter(|entries| !entries.is_empty() && entries.len() <= MAX_PRESENCE_ENTRIES) .ok_or_else(|| "argument_invalid:vouchers".to_string())?; - let entries = rows + let bounded = rows .iter() .map(|entry| { - Ok(ObservedEntry { - ledger: entry - .get("ledger") - .and_then(Value::as_str) - .ok_or_else(|| "argument_invalid:vouchers".to_string())?, - amount: entry - .get("amount") - .and_then(Value::as_str) - .ok_or_else(|| "argument_invalid:vouchers".to_string())?, - }) + only_known_keys(entry, &["ledger", "amount"], "vouchers")?; + let ledger = nested_text(entry, "ledger", "vouchers", name_limit)? + .ok_or_else(|| "argument_invalid:vouchers".to_string())?; + let amount = nested_text(entry, "amount", "vouchers", MAX_PRESENCE_AMOUNT_CHARS)? + .ok_or_else(|| "argument_invalid:vouchers".to_string())?; + Ok((ledger, amount)) }) .collect::, String>>()?; + let entries = bounded + .iter() + .map(|(ledger, amount)| ObservedEntry { + ledger: ledger.as_str(), + amount: amount.as_str(), + }) + .collect::>(); parsed.push( ProposedVoucher::new(ProposedVoucherInput { position, date: &date, - voucher_type, - voucher_number: voucher.get("voucher_number").and_then(Value::as_str), - remote_id: voucher.get("remote_id").and_then(Value::as_str), - party: voucher.get("party").and_then(Value::as_str), + voucher_type: &voucher_type, + voucher_number: voucher_number.as_deref(), + remote_id: remote_id.as_deref(), + party: party.as_deref(), entries: &entries, }) .map_err(|error| error.safe_reason_code().to_string())?, diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index aeeda0e0..337d9e55 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -167,6 +167,102 @@ fn an_unknown_numbering_method_is_refused_at_the_published_schema() { ); } +#[tokio::test] +async fn cross_input_refusals_also_cost_no_tally_read() { + // A date outside the window and an undeclared voucher type depend only on + // the arguments. Deferring them to the crate boundary would spend a + // company probe, a catalogue read and a full voucher window first. + let directory = tempfile::tempdir().expect("directory"); + let server = offline_server(directory.path()); + for (arguments, code) in [ + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[{"date":"20261015","voucher_type":"Journal", + "entries":[{"ledger":"Cash","amount":"-1.00"},{"ledger":"WR2 Sales","amount":"1.00"}]}]}), + "presence_window_does_not_cover", + ), + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[{"date":"20260901","voucher_type":"Part and Labour Sale", + "entries":[{"ledger":"Cash","amount":"-1.00"},{"ledger":"WR2 Sales","amount":"1.00"}]}]}), + "presence_numbering_method_undeclared", + ), + ] { + let response = server + .call_tool_response("voucher_presence", arguments) + .await; + assert_eq!( + response.value["structuredContent"]["result"]["error"]["code"], code, + "{code}" + ); + assert_eq!(response.value["structuredContent"]["evidence"]["bytes"], 0); + } +} + +#[tokio::test] +async fn nested_arguments_are_bounded_to_the_published_schema() { + let directory = tempfile::tempdir().expect("directory"); + let server = offline_server(directory.path()); + let long = "x".repeat(agent_import::MAX_MASTER_NAME_CHARS + 1); + let entries = + json!([{"ledger":"Cash","amount":"-1.00"},{"ledger":"WR2 Sales","amount":"1.00"}]); + for (vouchers, numbering, code) in [ + // A voucher number past the advertised 1024 characters. + ( + json!([{"date":"20260901","voucher_type":"Journal","voucher_number":long,"entries":entries}]), + json!([{"voucher_type":"Journal","numbering_method":"manual"}]), + "argument_invalid:vouchers", + ), + // A ledger name past the advertised limit. + ( + json!([{"date":"20260901","voucher_type":"Journal", + "entries":[{"ledger":long,"amount":"-1.00"},{"ledger":"WR2 Sales","amount":"1.00"}]}]), + json!([{"voucher_type":"Journal","numbering_method":"manual"}]), + "argument_invalid:vouchers", + ), + // An amount past the advertised 64 characters. + ( + json!([{"date":"20260901","voucher_type":"Journal", + "entries":[{"ledger":"Cash","amount":"1".repeat(65)},{"ledger":"WR2 Sales","amount":"1.00"}]}]), + json!([{"voucher_type":"Journal","numbering_method":"manual"}]), + "argument_invalid:vouchers", + ), + // A nested property the schema does not declare. + ( + json!([{"date":"20260901","voucher_type":"Journal","narration":"hello","entries":entries}]), + json!([{"voucher_type":"Journal","numbering_method":"manual"}]), + "argument_invalid:vouchers", + ), + // A blank nested string. + ( + json!([{"date":"20260901","voucher_type":"Journal","party":" ","entries":entries}]), + json!([{"voucher_type":"Journal","numbering_method":"manual"}]), + "argument_invalid:vouchers", + ), + // The same discipline on the numbering declaration. + ( + json!([{"date":"20260901","voucher_type":"Journal","entries":entries}]), + json!([{"voucher_type":"Journal","numbering_method":"manual","note":"x"}]), + "argument_invalid:numbering", + ), + ] { + let response = server + .call_tool_response( + "voucher_presence", + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":numbering,"vouchers":vouchers}), + ) + .await; + assert_eq!( + response.value["structuredContent"]["result"]["error"]["code"], code, + "{code}" + ); + assert_eq!(response.value["structuredContent"]["evidence"]["bytes"], 0); + } +} + // --- typed parses ------------------------------------------------------- #[test] @@ -280,44 +376,51 @@ fn window_xml() -> String { ) } -fn presence_plans() -> Vec { - let company = company_xml(); - let status = "TallyPrime Server is Running".to_string(); - let catalogue = catalogue_xml(); - let window = window_xml(); +/// The shapes the runtime actually issues: an identity pair, then one block +/// per paired native read. Built rather than hand-indexed, because this tool +/// performs three reads and an off-by-one in a literal list is a debugging +/// session, not a test failure. +enum Step { + Company, + Status, + Payload(String), +} + +fn paired_read(payload: &str) -> Vec { vec![ - company.clone(), - status.clone(), - company.clone(), - status.clone(), - company.clone(), - catalogue.clone(), - status.clone(), - catalogue, - status.clone(), - company.clone(), - company.clone(), - window.clone(), - status.clone(), - window, - status, - company, + Step::Company, + Step::Payload(payload.to_string()), + Step::Status, + Step::Payload(payload.to_string()), + Step::Status, + Step::Company, ] - .into_iter() - .enumerate() - .map(|(index, body)| { - if matches!(index, 1 | 3 | 6 | 8 | 12 | 14) { - ScenarioPlan::new(Fixture::ProductStatus( +} + +fn presence_plans() -> Vec { + let catalogue = catalogue_xml(); + let mut steps = vec![Step::Company, Step::Status, Step::Company, Step::Status]; + // Catalogue, then the voucher window, then the catalogue again: the + // verdict is built from two observations and the second read proves the + // first still holds. + steps.extend(paired_read(&catalogue)); + steps.extend(paired_read(&window_xml())); + steps.extend(paired_read(&catalogue)); + steps + .into_iter() + .map(|step| match step { + Step::Status => ScenarioPlan::new(Fixture::ProductStatus( tally_protocol_simulator::ProductStatus::TallyPrime, )) - .with_framing(ResponseFraming::ContentLength) - } else { - ScenarioPlan::new(Fixture::SyntheticXml(body)) + .with_framing(ResponseFraming::ContentLength), + Step::Company => ScenarioPlan::new(Fixture::SyntheticXml(company_xml())) .with_encoding(WireEncoding::Utf16Le) - .with_framing(ResponseFraming::ContentLength) - } - }) - .collect() + .with_framing(ResponseFraming::ContentLength), + Step::Payload(body) => ScenarioPlan::new(Fixture::SyntheticXml(body)) + .with_encoding(WireEncoding::Utf16Le) + .with_framing(ResponseFraming::ContentLength), + }) + .collect() } #[tokio::test] @@ -400,5 +503,5 @@ async fn a_live_shaped_cycle_separates_present_undecided_and_absent() { "complete" ); let observed = simulator.finish().expect("requests"); - assert_eq!(observed.len(), 16); + assert_eq!(observed.len(), 22); } From a4472091888a79ba155b457813fe9b197bdf5fac Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 02:51:35 +0530 Subject: [PATCH 27/91] Fix three more findings, one of them a claim my own new test made falsely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second review pass on the fixes. All three hold. Proposals sharing a REMOTEID fell through when the book held none. Proposal-side uniqueness was consulted only after the book lookup, so two source rows claiming one identity could each reach a resemblance verdict or absent — reporting colliding rows as safe to import. The check now runs before the lookup, matching how a duplicated manual number is already handled. My own invariant test was false. It asserted that an empty candidate list means only PartyNotDecidable; that stopped being true the moment a second empty-list reason existed, and it passed only because no case exercised one. Two proposals sharing a number the book does not hold produce ProposalNumberCollision with an empty list. The honest rule is weaker and more useful: an empty list is always a proposal-side condition — the book was never consulted, or was consulted about something undecidable before it could point anywhere — and never means "nothing in the book resembles this", which only absent means. The allow-list of empty-capable reasons now fails closed, and the test exercises all four. Having just told a sibling lane its empty vector was overloaded, writing a test that asserted the wrong thing about my own is worth recording rather than quietly correcting. The response could exceed its own byte cap. 500 proposals at 25 candidates is 12,500 objects, this shape is deliberately not pageable, and an over-large report is replaced wholesale by agent_response_too_large after every Tally read has been paid for. An aggregate candidate budget now keeps an admitted request retrievable, spent in proposal order and marking what it cut, so a trimmed list still carries its true count. Duplicate-number and unbalanced listings are bounded tighter for the same reason. 865 lib and 159 core green, clippy and fmt clean, reseal run and gate passing. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/book_presence.rs | 25 +++- .../src/book_presence_tests.rs | 131 ++++++++++++++++++ src-tauri/src/agent_presence.rs | 32 ++++- 4 files changed, 184 insertions(+), 8 deletions(-) diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index d9c2f861..60c26b7b 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "98824b5c84b888ef1150e8d4b6e9e0b3d15ac9fe40a3ee00693d8620230bf0f4" + "sha256": "3474681207f1fa351f102c484038ec65aded6f075f6f44ebee053acddeb4a830" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/agent_presence.rs", - "sha256": "d3fbe652cdeccc2fb0b4005d874f21a8c60820ee58e6e24e51860f89bad0c163" + "sha256": "eea47281f9d8b67ed6deff3059e44bbd346a45af84ccd580b1bd08d886269dfb" }, { "path": "src-tauri/src/agent_read_profiles.rs", diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index d295dda6..5d98b60e 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -38,11 +38,11 @@ pub const MAX_ENTRIES_PER_VOUCHER: usize = 2_000; /// Most candidates retained per undecided proposal. pub const MAX_CANDIDATES_PER_PROPOSAL: usize = 25; /// Most duplicate-number groups listed in the book observations. -pub const MAX_DUPLICATE_NUMBER_GROUPS: usize = 100; +pub const MAX_DUPLICATE_NUMBER_GROUPS: usize = 25; /// Most book keys listed inside one duplicate-number group. -pub const MAX_KEYS_PER_DUPLICATE_GROUP: usize = 25; +pub const MAX_KEYS_PER_DUPLICATE_GROUP: usize = 10; /// Most unbalanced book vouchers listed in the book observations. -pub const MAX_UNBALANCED_LISTED: usize = 100; +pub const MAX_UNBALANCED_LISTED: usize = 25; /// Longest accepted text field, in characters. This bounds pathological input; /// it is not a claim about what Tally accepts. pub const MAX_TEXT_CHARS: usize = 16_384; @@ -1108,8 +1108,23 @@ fn decide( // Rule one: identity first. A REMOTEID is a key Bridge itself wrote. if let Some(remote_id) = proposal.remote_id.as_deref() { - if let Some(matches) = index.by_remote_id.get(remote_id) { - let unique_here = proposal_remote_counts.get(remote_id).copied() == Some(1); + let unique_here = proposal_remote_counts.get(remote_id).copied() == Some(1); + let empty = Vec::new(); + let matches = index.by_remote_id.get(remote_id).unwrap_or(&empty); + // Proposal-side uniqueness is checked *before* the book lookup, the + // same way a duplicated manual number is. Two source rows claiming one + // identity are undecidable whether or not the book holds it, and + // falling through would report both as safe to import. + if !unique_here { + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::RemoteIdCollision, + candidates_from(window, matches, CandidateRule::SharedRemoteId), + )), + matches.iter().copied().collect(), + ); + } + if !matches.is_empty() { if matches.len() == 1 && unique_here { return shell( settled( diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 582540a5..2f78bd52 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1246,6 +1246,137 @@ fn an_untruncated_empty_candidate_list_still_permits_absent() { assert!(!resolution.incomplete); } +/// The overloaded-empty-vector shape has been got wrong twice in two surfaces, +/// so this contract states what its own empty list means — and the honest +/// statement is not "one thing". An empty list is always a *proposal-side* +/// condition: the book was never consulted, or was consulted about something +/// undecidable before it could point anywhere. What it never means is +/// "nothing in the book resembles this" — only `Absent` means that, and +/// `Absent` carries no list at all. +/// +/// An earlier version of this test asserted the stronger claim that empty +/// implies `PartyNotDecidable`. That was false the moment a second empty-list +/// reason existed, and it passed only because no case exercised one. The +/// allow-list below is the real rule and fails closed: a new reason that can +/// arrive empty must be added here deliberately. +const EMPTY_LIST_REASONS: [UndecidedReason; 4] = [ + UndecidedReason::PartyNotDecidable, + UndecidedReason::RemoteIdEvidenceUnavailable, + UndecidedReason::ProposalNumberCollision, + UndecidedReason::RemoteIdCollision, +]; + +#[test] +fn an_empty_candidate_list_is_always_a_proposal_side_condition() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let mut names: Vec = (1..=30) + .map(|index| format!("Echo Party {index:03}")) + .collect(); + names.extend(LEDGERS.iter().map(|name| (*name).to_string())); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("catalog"); + let money = |ledger: &'static str| vec![[ledger, "-99.00"], ["Sales Account", "99.00"]]; + let proposals = [ + // Party family that cannot be distinguished. + ProposalRow::new(0, "20260812", "AA0501") + .party("Echo Party 0") + .rows(money("Echo Party 0")) + .build(), + // Two proposals sharing a manual number the book does not hold. + ProposalRow::new(1, "20260812", "AA0502") + .party("Charlie Minerals") + .rows(money("Charlie Minerals")) + .build(), + ProposalRow::new(2, "20260812", "AA0502") + .party("Charlie Minerals") + .rows(money("Charlie Minerals")) + .build(), + // Two proposals sharing a REMOTEID the book does not hold. + ProposalRow::new(3, "20260812", "AA0503") + .remote_id("tally-9") + .party("Charlie Minerals") + .rows(money("Charlie Minerals")) + .build(), + ProposalRow::new(4, "20260812", "AA0504") + .remote_id("tally-9") + .party("Charlie Minerals") + .rows(money("Charlie Minerals")) + .build(), + ]; + let report = run( + &window, + &catalog, + &numbering(NumberingMethod::Manual), + &proposals, + ); + let mut empties = 0; + for entry in report.vouchers() { + let Some(undecided) = entry.undecided() else { + continue; + }; + if undecided.candidates.is_empty() { + empties += 1; + assert!( + EMPTY_LIST_REASONS.contains(&undecided.reason), + "{:?} may not arrive with an empty candidate list", + undecided.reason + ); + assert!(!undecided.candidates_truncated); + } else { + assert_eq!( + undecided.candidates_truncated, + undecided.candidates.len() < undecided.candidate_count + ); + } + } + assert_eq!(empties, 5, "every empty-list reason must be exercised here"); + let totals = report.totals(); + assert_eq!( + totals.present + totals.possibly_present + totals.absent, + totals.requested + ); +} + +/// Two source rows claiming one identity are undecidable whether or not the +/// book holds that identity. Consulting the book first let both fall through +/// to a resemblance verdict, or to `Absent` — reporting colliding rows as safe +/// to import. +#[test] +fn proposals_sharing_a_remote_id_collide_even_when_the_book_has_none() { + let window = window(&[BookRow::new("book-1", "20260819", "AA0130").party("Bravo Industries")]); + let proposals = [ + ProposalRow::new(0, "20260812", "AA0601") + .remote_id("tally-9") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-42.00"], + ["Sales Account", "42.00"], + ]) + .build(), + ProposalRow::new(1, "20260812", "AA0602") + .remote_id("tally-9") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-43.00"], + ["Sales Account", "43.00"], + ]) + .build(), + ]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!( + report.totals().absent, + 0, + "colliding rows are not safe to import" + ); + for entry in report.vouchers() { + assert_eq!(reason(entry), UndecidedReason::RemoteIdCollision); + } +} + /// The overloaded-empty-vector shape has now been got wrong twice in two /// surfaces, so this contract's *own* output must not repeat it. Here an empty /// candidate list is one fact and not three: it happens only when the party diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs index fbfe568b..af1f8acd 100644 --- a/src-tauri/src/agent_presence.rs +++ b/src-tauri/src/agent_presence.rs @@ -24,6 +24,16 @@ pub(super) const MAX_PRESENCE_VOUCHER_TYPES: usize = 50; pub(super) const MAX_PRESENCE_ENTRIES: usize = 200; /// Longest accepted amount lexeme, matching the published schema. const MAX_PRESENCE_AMOUNT_CHARS: usize = 64; +/// Candidates this response may carry in total, across every proposal. +/// +/// The per-proposal cap alone does not bound the response: 500 proposals at 25 +/// candidates each is 12,500 objects, and this result shape is deliberately +/// **not** pageable — `page_shape` cannot trim it, so an over-large report is +/// replaced wholesale by `agent_response_too_large` *after* every Tally read +/// has been paid for. An aggregate budget keeps an admitted request +/// retrievable. Spending it in proposal order, and marking what it cut, is the +/// same discipline `master_binding` applies to its own report. +const MAX_PRESENCE_RESPONSE_CANDIDATES: usize = 1_000; /// The shared argument validator bounds only the outer arrays, and the core /// crate's own limits are far wider than what this tool advertises. So every @@ -340,17 +350,37 @@ fn presence_result( corroboration_reason: Option<&'static str>, ) -> Value { let (from, to) = report.window(); + let mut budget = MAX_PRESENCE_RESPONSE_CANDIDATES; let vouchers = report .vouchers() .iter() - .map(|entry| mark_presence_party_names(serde_json::to_value(entry).unwrap_or_default())) + .map(|entry| { + let mut value = + mark_presence_party_names(serde_json::to_value(entry).unwrap_or_default()); + // A trimmed list keeps its true count and says it was cut, so an + // empty list here still never reads as "nothing resembles this". + if let Some(candidates) = value.get_mut("candidates").and_then(Value::as_array_mut) { + if candidates.len() > budget { + candidates.truncate(budget); + value["candidates_truncated"] = Value::Bool(true); + } + let spent = value["candidates"] + .as_array() + .map(Vec::len) + .unwrap_or_default(); + budget = budget.saturating_sub(spent); + } + value + }) .collect::>(); + let candidate_budget_exhausted = budget == 0; json!({ "profile": "agent_voucher_presence_v1", // Every verdict is relative to this window. `absent` means absent from // this range and never absent from the book. "window": {"from": from, "to": to, "read": "complete", "reason": corroboration_reason}, "vouchers": vouchers, + "candidate_budget_exhausted": candidate_budget_exhausted, "totals": report.totals(), "book": report.observations(), "catalogue_evidence_sha256": sha256_json(&catalogue.to_vec()), From 9ae3b0a514c79e9396924c40a68c69783076be38 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 02:56:07 +0530 Subject: [PATCH 28/91] Resolve both identities before either settles, and never settle on unread evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more findings, and the first overturns reasoning I defended in a thread reply rather than reasoning I had merely overlooked. I had argued that a manual-number match should still produce present when the proposal carries a REMOTEID the window never read, because the number is decisive on its own terms and withholding it would make the tool less useful for callers who supply identifiers. That is the posture in ADR 0017 section 7 argued backwards. Present carries the higher bar precisely because its error is the silent one, so evidence that could contradict it and was never gathered must fail toward not-present. Withheld as RemoteIdEvidenceUnavailable now; a proposal carrying no REMOTEID skipped nothing and still settles, so the common path — and both motivating engagements, where no proposal has one — is unchanged. Second: a REMOTEID selecting one voucher while the number selected a different one settled on the REMOTEID, because the early return fired before the number lookup ran. That ranked one identity basis over the other on evaluation order alone. Both lookups are now resolved before either settles, and a disagreement returns IdentityConflict carrying both vouchers, each labelled by the rule that surfaced it. Four tests, each with the contrast that keeps the rule from widening: an unread REMOTEID withholds, no REMOTEID still settles, disagreeing identities conflict, agreeing identities settle. A third finding, that the surface digests were stale, was raised against the previous commit and is already fixed; the gate passes on HEAD. 163 core and 865 lib green, clippy and fmt clean, gate passing. Co-Authored-By: Claude Opus 5 --- docs/adr/0017-voucher-presence-authority.md | 23 +++- .../compatibility/compatibility-surface.json | 2 +- .../bridge-tally-core/src/book_presence.rs | 83 ++++++++++---- .../src/book_presence_tests.rs | 103 ++++++++++++++++++ 4 files changed, 183 insertions(+), 28 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 9a23f884..64558031 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -213,11 +213,24 @@ leaves open: row and silently drop an invoice, which is the failure this contract exists to prevent. Every claimant of a contested voucher is demoted to `BookVoucherClaimedTwice`; choosing between them would be auto-resolution. -- **Two identity signals that disagree are reported, not ranked.** Where a - number matches uniquely but the two sides carry *different* `REMOTEID`s, the - result is `IdentityConflict` rather than a `Present` settled in the number's - favour — the same rule ADR 0016 applies when an identifier contradicts an - exact name. +- **Two identity signals that disagree are reported, not ranked.** Both + lookups are resolved *before* either settles, so a `REMOTEID` selecting one + voucher while the number selects another is `IdentityConflict` — as is a + number matching uniquely while the two sides carry different `REMOTEID`s. + Settling on whichever basis happened to be evaluated first would rank them, + which is the move ADR 0016 refuses when an identifier contradicts an exact + name. +- **Evidence that was never gathered cannot settle a `Present` either.** The + rule that withholds `Absent` when a key was not compared applies with more + force to `Present`, because `Present` carries the higher bar and its error is + the silent one. So where a proposal supplies a `REMOTEID` and the window is + `RemoteIdEvidence::NotRead`, a unique number match returns + `RemoteIdEvidenceUnavailable` rather than `Present`: the number is decisive + on its own terms, but the evidence that could contradict it was skipped. A + proposal carrying no `REMOTEID` skipped nothing and still settles. An earlier + revision of this ADR allowed that `Present`, reasoning that withholding it + would make the tool less useful — which is the posture in §7 argued + backwards, and review caught it. Number comparison uses the same NFC / dash-and-quote / case / whitespace comparison key as master binding, so a long alphanumeric invoice number and its diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 60c26b7b..537b216c 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "3474681207f1fa351f102c484038ec65aded6f075f6f44ebee053acddeb4a830" + "sha256": "ba06ef8501aaa4539510afea020f2882affceb13662ac4f18a18ce3559dd9dc5" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 5d98b60e..4b90aba9 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -1106,6 +1106,28 @@ fn decide( let remote_id_unverifiable = proposal.remote_id.is_some() && window.remote_id_evidence() == RemoteIdEvidence::NotRead; + // Rule two: a voucher number is identity only where the numbering method + // preserves it (§9.8), and only when it is unique on both sides. + let number_matches: Vec = proposal + .number_key + .as_deref() + .map(|number_key| { + if type_observed { + index + .by_type_and_number + .get(&(proposal.type_key.as_str(), number_key)) + .cloned() + .unwrap_or_default() + } else { + // The type name was never observed, so it discriminates + // nothing. Widen rather than manufacture an absence. + index.by_number.get(number_key).cloned().unwrap_or_default() + } + }) + .unwrap_or_default(); + + // Manual numbering only decides within an observed voucher type: numbers + // are a per-type series, so a cross-type number match is a resemblance. // Rule one: identity first. A REMOTEID is a key Bridge itself wrote. if let Some(remote_id) = proposal.remote_id.as_deref() { let unique_here = proposal_remote_counts.get(remote_id).copied() == Some(1); @@ -1126,6 +1148,32 @@ fn decide( } if !matches.is_empty() { if matches.len() == 1 && unique_here { + // Both identities are resolved before either settles. A + // REMOTEID selecting one voucher while the number selects + // another is two identity signals disagreeing, and ranking one + // of them is the move this contract refuses everywhere else. + let number_selects_another = method == NumberingMethod::Manual + && type_observed + && number_matches.len() == 1 + && number_matches[0] != matches[0]; + if number_selects_another { + let mut touched = BTreeSet::from([matches[0]]); + touched.insert(number_matches[0]); + let mut candidates = + candidates_from(window, matches, CandidateRule::SharedRemoteId); + candidates.extend(candidates_from( + window, + &number_matches, + CandidateRule::SharedVoucherNumber, + )); + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::IdentityConflict, + candidates, + )), + touched, + ); + } return shell( settled( proposal, @@ -1146,28 +1194,6 @@ fn decide( } } - // Rule two: a voucher number is identity only where the numbering method - // preserves it (§9.8), and only when it is unique on both sides. - let number_matches: Vec = proposal - .number_key - .as_deref() - .map(|number_key| { - if type_observed { - index - .by_type_and_number - .get(&(proposal.type_key.as_str(), number_key)) - .cloned() - .unwrap_or_default() - } else { - // The type name was never observed, so it discriminates - // nothing. Widen rather than manufacture an absence. - index.by_number.get(number_key).cloned().unwrap_or_default() - } - }) - .unwrap_or_default(); - - // Manual numbering only decides within an observed voucher type: numbers - // are a per-type series, so a cross-type number match is a resemblance. if method == NumberingMethod::Manual && type_observed { if let Some(number_key) = proposal.number_key.as_deref() { let proposed_twice = proposal_number_counts @@ -1212,6 +1238,19 @@ fn decide( (Some(proposed), Some(observed)) => proposed != observed, _ => false, }; + if remote_id_unverifiable { + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::RemoteIdEvidenceUnavailable, + candidates_from( + window, + &number_matches, + CandidateRule::SharedVoucherNumber, + ), + )), + touched, + ); + } if contradicted { return shell( PresenceStatus::PossiblyPresent(undecided( diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 2f78bd52..bfb0258a 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1665,3 +1665,106 @@ fn a_voucher_with_no_party_field_has_nothing_to_disagree_with() { }; assert!(differences.is_empty()); } + +/// `Present` carries the higher bar, so unobserved evidence that could +/// *contradict* it must fail toward not-present. A number match while the +/// proposal's own `REMOTEID` was never compared settles on one identity while +/// the other is unknown — and a wrong `Present` suppresses a real invoice. +#[test] +fn a_number_match_cannot_settle_while_the_proposals_remote_id_is_unread() { + let unread = BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::NotRead, + vec![BookRow::new("book-1", "20260812", "AA0118").build()], + ) + .expect("window"); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .remote_id("tally-1") + .build()]; + let report = run( + &unread, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::RemoteIdEvidenceUnavailable); + // The number match is still shown, so the operator sees what it resembles. + assert_eq!( + entry.undecided().expect("undecided").candidates[0].book_key, + "book-1" + ); +} + +#[test] +fn a_proposal_without_a_remote_id_still_settles_on_an_unread_window() { + let unread = BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::NotRead, + vec![BookRow::new("book-1", "20260812", "AA0118").build()], + ) + .expect("window"); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &unread, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + // Nothing was skipped: this proposal carries no REMOTEID to compare. + assert_eq!(only(&report).present_book_key(), Some("book-1")); +} + +/// Both identity lookups are resolved before either settles. A `REMOTEID` +/// selecting one voucher while the number selects another is a disagreement, +/// and ranking the basis that happened to be checked first is the move this +/// contract refuses everywhere else. +#[test] +fn a_remote_id_and_a_number_selecting_different_vouchers_do_not_settle() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118").remote_id("tally-1"), + BookRow::new("book-2", "20260813", "AA0119").party("Bravo Industries"), + ]); + let proposals = [ProposalRow::new(0, "20260813", "AA0119") + .remote_id("tally-1") + .party("Bravo Industries") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::IdentityConflict); + // Both contradicting vouchers are shown, each labelled by its own rule. + let candidates = &entry.undecided().expect("undecided").candidates; + assert_eq!(candidates.len(), 2); + assert!(candidates + .iter() + .any(|c| c.book_key == "book-1" && c.rule == CandidateRule::SharedRemoteId)); + assert!(candidates + .iter() + .any(|c| c.book_key == "book-2" && c.rule == CandidateRule::SharedVoucherNumber)); +} + +#[test] +fn a_remote_id_and_a_number_agreeing_on_one_voucher_still_settle() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").remote_id("tally-1")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .remote_id("tally-1") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(only(&report).present_book_key(), Some("book-1")); +} From c6542c983f34e9b5ff426bc931ade6745bfd6999 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 03:13:28 +0530 Subject: [PATCH 29/91] Consume the typed candidate listing, and delete what it made redundant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit after this lane voted for it and priced the change at thirty lines. The estimate held: one function, two arms, two test literals. The point was never compatibility. The predicate was incomplete: unresolved.reason == UnboundReason::NoDiscriminatingCandidate || unresolved.candidates_truncated — a hand-assembled disjunction over two fields, which is the line that collapses into "no candidates means unknown" if edited carelessly, and the reason it needed a paired test to hold it. It is now an exhaustive match over None, Listed, Truncated and Withheld, so a new variant does not compile until this decides what it means, and the difference between "nothing resembles this party" and "a family we refuse to slice" lives in one place instead of being reconstructed from a reason code. The UnboundReason import is gone: the type carries what the reason comparison was standing in for. Net effect is fewer lines and one less thing to remember. 167 core and 865 lib green, clippy and fmt clean, reseal run and gate passing at 213 pins. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-surface.json | 2 +- .../bridge-tally-core/src/book_presence.rs | 71 ++++++++++++------- .../src/book_presence_tests.rs | 19 ++--- 3 files changed, 56 insertions(+), 36 deletions(-) diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 537b216c..e72dff8d 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "ba06ef8501aaa4539510afea020f2882affceb13662ac4f18a18ce3559dd9dc5" + "sha256": "8ea0292b90f89de622f53b3b30d7a28005d49f0a817effe447046cefe21d2227" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 4b90aba9..0703bb6f 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -23,8 +23,8 @@ use serde::{Deserialize, Serialize}; use crate::exact_arithmetic::ExactDecimalAccumulator; use crate::master_binding::{ - self, comparison_key, BindingStatus, MasterBindingError, MasterCatalog, MasterClass, - SourceEntity, UnboundReason, + self, comparison_key, BindingStatus, Candidates, MasterBindingError, MasterCatalog, + MasterClass, SourceEntity, }; use crate::{ExactDecimal, TallyDate}; @@ -879,6 +879,28 @@ fn bind_parties( } fn resolution_of(binding: &master_binding::EntityBinding) -> PartyResolution { + // Matched exhaustively rather than read through accessors: these four + // cases are the reason ADR 0016 replaced a vector plus two flags with a + // type, and a new one must not compile until this decides what it means. + // `listed` is empty for `None` and `Withheld` alike, so the difference + // between "nothing resembles this party" and "a family we refuse to slice" + // lives only here. + fn from(unresolved: &master_binding::Unresolved) -> (BTreeSet, bool) { + let keys = |listed: &[master_binding::Candidate]| { + listed + .iter() + .map(|candidate| comparison_key(&candidate.catalog_name)) + .collect::>() + }; + match &unresolved.candidates { + // Nothing resembles the party, and that is information. + Candidates::None => (BTreeSet::new(), false), + Candidates::Listed(listed) => (keys(listed), false), + // Names exist that were never compared, either way. + Candidates::Truncated { listed, .. } => (keys(listed), true), + Candidates::Withheld { .. } => (BTreeSet::new(), true), + } + } match &binding.status { BindingStatus::Bound { catalog_name, .. } => PartyResolution { outcome: PartyOutcome::Bound { @@ -889,32 +911,29 @@ fn resolution_of(binding: &master_binding::EntityBinding) -> PartyResolution { }, // Every candidate is compared, never one of them. Widening the net can // only produce more resemblance, which is the safe direction here. - BindingStatus::Ambiguous(unresolved) => PartyResolution { - outcome: PartyOutcome::Ambiguous { - reason: unresolved.reason.safe_reason_code().to_string(), - candidate_count: unresolved.candidate_count, - }, - compare_keys: unresolved - .candidates - .iter() - .map(|candidate| comparison_key(&candidate.catalog_name)) - .collect(), - // A name family is deliberately not listed, and a truncated list - // leaves names uncompared. Either way `Absent` would rest on a - // comparison that never ran. - incomplete: unresolved.reason == UnboundReason::NoDiscriminatingCandidate - || unresolved.candidates_truncated, - }, + BindingStatus::Ambiguous(unresolved) => { + let (compare_keys, incomplete) = from(unresolved); + PartyResolution { + outcome: PartyOutcome::Ambiguous { + reason: unresolved.reason.safe_reason_code().to_string(), + candidate_count: unresolved.candidates.found(), + }, + compare_keys, + incomplete, + } + } // Nothing in this book resembles the party, so no posted voucher can // be carrying it. Party rules simply do not run. - BindingStatus::Unmatched(unresolved) => PartyResolution { - outcome: PartyOutcome::Unmatched { - reason: unresolved.reason.safe_reason_code().to_string(), - }, - compare_keys: BTreeSet::new(), - incomplete: unresolved.reason == UnboundReason::NoDiscriminatingCandidate - || unresolved.candidates_truncated, - }, + BindingStatus::Unmatched(unresolved) => { + let (compare_keys, incomplete) = from(unresolved); + PartyResolution { + outcome: PartyOutcome::Unmatched { + reason: unresolved.reason.safe_reason_code().to_string(), + }, + compare_keys, + incomplete, + } + } } } diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index bfb0258a..471972d2 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1206,12 +1206,15 @@ fn an_aggregately_truncated_candidate_list_withholds_absent() { position: 0, source_name: "Delta Trading".to_string(), status: BindingStatus::Ambiguous(master_binding::Unresolved { - reason: UnboundReason::NearMiss, + reason: master_binding::UnboundReason::NearMiss, unresolved_identity: Vec::new(), - // Empty, yet seven candidates were found before the budget ran out. - candidates: Vec::new(), - candidate_count: 7, - candidates_truncated: true, + // Empty, yet seven candidates were found before the budget ran + // out. Under the typed listing this is `Truncated` with nothing + // listed, which is now a state the compiler makes me handle. + candidates: master_binding::Candidates::Truncated { + listed: Vec::new(), + found: 7, + }, }), }; let resolution = resolution_of(&binding); @@ -1234,11 +1237,9 @@ fn an_untruncated_empty_candidate_list_still_permits_absent() { position: 0, source_name: "Zulu Enterprises".to_string(), status: BindingStatus::Unmatched(master_binding::Unresolved { - reason: UnboundReason::NoCandidate, + reason: master_binding::UnboundReason::NoCandidate, unresolved_identity: Vec::new(), - candidates: Vec::new(), - candidate_count: 0, - candidates_truncated: false, + candidates: master_binding::Candidates::None, }), }; let resolution = resolution_of(&binding); From c2d9ebe0fb97155acdfd9c842dddbe757a998649 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 03:26:57 +0530 Subject: [PATCH 30/91] Delete what nothing reads, and let the schema be the only copy of a bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reductions, both removing a thing rather than adding one. Dead public surface. Six accessors nothing anywhere called — BookVoucher's voucher_type, voucher_number and posting, and ProposedVoucher's position, magnitude and balanced — plus the balanced field itself, which was computed on every proposal and never read. A pub accessor on a lib crate does not trip dead_code, so it has to be audited deliberately or it accumulates. Nested bounds had two copies. The adapter restated every limit the published inputSchema already states — 1024 characters here, 64 there, additionalProperties there — which is two descriptions of one contract and the copy that drifts is the one nobody is looking at. validate_against_schema now reads type, enum, string and array bounds, required and additionalProperties straight from the fragment. It lives in agent_catalog beside the existing validator rather than in this tool, so the next nested schema reuses it. It is deliberately not wired into validate_tool_arguments: every tool predating nested inputs owns a typed boundary below that line, and tightening the shared path would change their refusal codes for no defect. The test that proves it now reads maxLength out of the schema too, and asserts the boundary tracks it — exactly at the limit accepted, one past refused. A test restating the bound would have been the third copy. Net: agent_presence 413 to 355 lines, book_presence 1598 to 1574, with 75 reusable lines added where any tool can reach them. 865 lib and 167 core green, clippy and fmt clean, gate passing. Co-Authored-By: Claude Opus 5 --- docs/adr/0017-voucher-presence-authority.md | 17 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/book_presence.rs | 31 +--- src-tauri/src/agent_catalog.rs | 78 +++++++++ src-tauri/src/agent_presence.rs | 149 ++++++------------ src-tauri/src/agent_presence_tests.rs | 39 +++++ 6 files changed, 180 insertions(+), 138 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 64558031..82755a12 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -382,12 +382,17 @@ human-approved batch — this ADR does not move. enforces them again at its own boundary; the adapter check exists so a request that was always going to be refused does not first spend a company probe, a catalogue read and a full window read. -- **The published `inputSchema` is enforced to its leaves.** The shared - argument validator bounds only outer arrays and the crate's own limits are far - wider than this tool advertises, so nested names, numbers, identifiers and - amounts are bounded at the adapter and an undeclared nested property is - refused. A schema promising `additionalProperties: false` that then accepts - them is a claim the boundary does not keep. +- **The published `inputSchema` is enforced to its leaves, and by the schema + itself.** The shared argument validator bounds only outer arrays — every tool + predating nested inputs owns a typed boundary below that line, so tightening + the shared path would change their refusal codes — and the crate's own limits + are far wider than this tool advertises. The gap is closed by + `validate_against_schema`, a small recursive check that reads `type`, `enum`, + string and array bounds, `required` and `additionalProperties` straight from + the published fragment. Restating those limits in the parser would put two + copies of every bound in the tree, and the copy that drifts is the one nobody + is looking at. The helper lives beside the existing validator so the next + tool with a nested schema reuses it rather than restating anything. - Voucher numbers and voucher-type names fold through `master_binding::comparison_key` — the *same* key master names use, now an explicit crate-wide contract point owned by ADR 0016 rather than a private diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index e72dff8d..8fb9d107 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "8ea0292b90f89de622f53b3b30d7a28005d49f0a817effe447046cefe21d2227" + "sha256": "7c7c8699f0e774e7999287a9ad392ccb329ebd1bd7614cb36b245d8a0a2f4c17" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/agent_presence.rs", - "sha256": "eea47281f9d8b67ed6deff3059e44bbd346a45af84ccd580b1bd08d886269dfb" + "sha256": "e318fe6a11807e3191ac36f7639e12125753b5f2c1a4b6d347e1779986ac7234" }, { "path": "src-tauri/src/agent_read_profiles.rs", diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 0703bb6f..9ded173e 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -273,14 +273,6 @@ impl BookVoucher { self.date.as_str() } - pub fn voucher_type(&self) -> &str { - &self.voucher_type - } - - pub fn voucher_number(&self) -> Option<&str> { - self.voucher_number.as_deref() - } - pub fn party(&self) -> Option<&str> { self.party.as_deref() } @@ -289,10 +281,6 @@ impl BookVoucher { &self.magnitude } - pub fn posting(&self) -> PostingState { - self.posting - } - /// Whether the observed entries summed to zero. An unbalanced voucher is /// reported and still participates in every rule: excluding it would make /// `Absent` more likely, which is the wrong direction. @@ -310,7 +298,6 @@ pub struct ProposedVoucher { remote_id: Option, party: Option, magnitude: ExactDecimal, - balanced: bool, type_key: String, number_key: Option, } @@ -323,7 +310,7 @@ impl ProposedVoucher { let voucher_number = input.voucher_number.map(validated_text).transpose()?; let remote_id = input.remote_id.map(validated_text).transpose()?; let party = input.party.map(validated_text).transpose()?; - let (magnitude, balanced, _) = magnitude_of(input.entries)?; + let (magnitude, _, _) = magnitude_of(input.entries)?; let type_key = comparison_key(&voucher_type); let number_key = voucher_number.as_deref().map(comparison_key); Ok(Self { @@ -334,31 +321,21 @@ impl ProposedVoucher { remote_id, party, magnitude, - balanced, type_key, number_key, }) } - pub fn position(&self) -> usize { - self.position - } - pub fn date(&self) -> &str { self.date.as_str() } + /// The type as the source document spelled it. A consumer validating its + /// own arguments against a `NumberingDeclaration` needs this; nothing else + /// does. pub fn voucher_type(&self) -> &str { &self.voucher_type } - - pub fn magnitude(&self) -> &ExactDecimal { - &self.magnitude - } - - pub fn balanced(&self) -> bool { - self.balanced - } } /// One observed window of a company's book. It can only be constructed from a diff --git a/src-tauri/src/agent_catalog.rs b/src-tauri/src/agent_catalog.rs index 5d5584e9..beac8334 100644 --- a/src-tauri/src/agent_catalog.rs +++ b/src-tauri/src/agent_catalog.rs @@ -90,6 +90,84 @@ pub(super) fn validate_tool_arguments(name: &str, args: &Value) -> Result<(), St Ok(()) } +/// Validates a value against a published schema fragment, recursively. +/// +/// [`validate_tool_arguments`] deliberately stops at the outer selectors, +/// because every tool that predates nested inputs owns its own typed boundary +/// below that line and tightening the shared path would change their refusal +/// codes. A tool whose `inputSchema` *does* describe nested objects calls this +/// instead of restating those bounds in its parser: two copies of one bound +/// drift, and the copy that drifts is the one nobody is looking at. +/// +/// It enforces exactly what the fragment states — `type`, `enum`, string +/// bounds, array bounds, `required`, and `additionalProperties: false` — and +/// nothing it does not, so a schema remains the single description of what a +/// caller may send. +pub(super) fn validate_against_schema( + value: &Value, + schema: &Value, + key: &str, +) -> Result<(), String> { + let invalid = || format!("argument_invalid:{key}"); + if schema["enum"] + .as_array() + .is_some_and(|allowed| !allowed.contains(value)) + { + return Err(invalid()); + } + match schema["type"].as_str() { + Some("string") => { + let text = value.as_str().ok_or_else(invalid)?; + validate_string_bounds(text, schema, key)?; + } + Some("integer") => { + let number = value.as_u64().ok_or_else(invalid)?; + if schema["minimum"].as_u64().is_some_and(|min| number < min) { + return Err(invalid()); + } + } + Some("array") => { + let items = value.as_array().ok_or_else(invalid)?; + if schema["minItems"] + .as_u64() + .is_some_and(|min| items.len() < min as usize) + || schema["maxItems"] + .as_u64() + .is_some_and(|max| items.len() > max as usize) + { + return Err(invalid()); + } + for item in items { + validate_against_schema(item, &schema["items"], key)?; + } + } + Some("object") => { + let object = value.as_object().ok_or_else(invalid)?; + let properties = schema["properties"].as_object(); + if schema["additionalProperties"] == Value::Bool(false) + && object + .keys() + .any(|name| !properties.is_some_and(|properties| properties.contains_key(name))) + { + return Err(invalid()); + } + for required in schema["required"].as_array().into_iter().flatten() { + let name = required.as_str().ok_or_else(invalid)?; + if !object.contains_key(name) { + return Err(invalid()); + } + } + for (name, member) in object { + if let Some(fragment) = properties.and_then(|properties| properties.get(name)) { + validate_against_schema(member, fragment, key)?; + } + } + } + _ => {} + } + Ok(()) +} + fn validate_string_bounds(text: &str, schema: &Value, key: &str) -> Result<(), String> { let length = text.chars().count(); if schema["minLength"] diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs index af1f8acd..62925ffd 100644 --- a/src-tauri/src/agent_presence.rs +++ b/src-tauri/src/agent_presence.rs @@ -22,49 +22,35 @@ pub(super) const MAX_PRESENCE_VOUCHERS: usize = 500; pub(super) const MAX_PRESENCE_VOUCHER_TYPES: usize = 50; /// Most ledger entries one proposed voucher may carry. pub(super) const MAX_PRESENCE_ENTRIES: usize = 200; -/// Longest accepted amount lexeme, matching the published schema. -const MAX_PRESENCE_AMOUNT_CHARS: usize = 64; /// Candidates this response may carry in total, across every proposal. /// -/// The per-proposal cap alone does not bound the response: 500 proposals at 25 -/// candidates each is 12,500 objects, and this result shape is deliberately -/// **not** pageable — `page_shape` cannot trim it, so an over-large report is -/// replaced wholesale by `agent_response_too_large` *after* every Tally read -/// has been paid for. An aggregate budget keeps an admitted request -/// retrievable. Spending it in proposal order, and marking what it cut, is the -/// same discipline `master_binding` applies to its own report. +/// The per-proposal cap alone does not bound the response, and this result +/// shape is deliberately **not** pageable — `page_shape` cannot trim it, so an +/// over-large report is replaced wholesale by `agent_response_too_large` +/// *after* every Tally read has been paid for. An aggregate budget keeps an +/// admitted request retrievable. Spending it in proposal order, and marking +/// what it cut, is the same discipline `master_binding` applies to its own +/// report. const MAX_PRESENCE_RESPONSE_CANDIDATES: usize = 1_000; -/// The shared argument validator bounds only the outer arrays, and the core -/// crate's own limits are far wider than what this tool advertises. So every -/// nested string is bounded here against the published `inputSchema`, and an -/// unknown nested property is refused rather than ignored — a schema that -/// promises `additionalProperties: false` and then accepts them is a claim the -/// boundary does not keep. -fn nested_text( - object: &Value, - key: &str, - argument: &str, - max_chars: usize, -) -> Result, String> { - let Some(value) = object.get(key) else { - return Ok(None); - }; - let text = value - .as_str() - .ok_or_else(|| format!("argument_invalid:{argument}"))?; - if text.trim().is_empty() || text.chars().count() > max_chars { - return Err(format!("argument_invalid:{argument}")); - } - Ok(Some(text.to_string())) -} - -fn only_known_keys(object: &Value, known: &[&str], argument: &str) -> Result<(), String> { - let map = object - .as_object() - .ok_or_else(|| format!("argument_invalid:{argument}"))?; - if map.keys().any(|key| !known.contains(&key.as_str())) { - return Err(format!("argument_invalid:{argument}")); +/// Enforces the published `inputSchema` on this tool's nested arrays. +/// +/// The shared argument validator stops at the outer selectors, and the core +/// crate's own limits are far wider than this tool advertises, so the gap has +/// to be closed somewhere. Closing it by restating the bounds in this parser +/// would put two copies of every limit in the tree; driving it from the schema +/// itself keeps one. +fn enforce_published_schema(args: &Value) -> Result<(), String> { + let definitions = catalog::registered_tool_definitions(true, true); + let schema = definitions + .as_array() + .and_then(|tools| tools.iter().find(|tool| tool["name"] == "voucher_presence")) + .map(|tool| tool["inputSchema"].clone()) + .ok_or_else(|| "tool_not_found".to_string())?; + for key in ["numbering", "vouchers"] { + if let Some(value) = args.get(key) { + catalog::validate_against_schema(value, &schema["properties"][key], key)?; + } } Ok(()) } @@ -79,6 +65,7 @@ impl Server { } // Parse the caller's own input before any Tally read: a malformed // proposal set should never cost a read. + enforce_published_schema(args)?; let numbering = parse_numbering(args)?; let proposals = parse_proposals(args)?; // Both remaining cross-input refusals depend only on the arguments, so @@ -245,25 +232,19 @@ fn parse_numbering(args: &Value) -> Result { .get("numbering") .and_then(Value::as_array) .ok_or_else(|| "numbering_required".to_string())?; - if declared.is_empty() || declared.len() > MAX_PRESENCE_VOUCHER_TYPES { - return Err("argument_invalid:numbering".to_string()); - } let entries = declared .iter() .map(|entry| { - only_known_keys(entry, &["voucher_type", "numbering_method"], "numbering")?; - let voucher_type = nested_text( - entry, - "voucher_type", - "numbering", - agent_import::MAX_MASTER_NAME_CHARS, - )? - .ok_or_else(|| "argument_invalid:numbering".to_string())?; - let method = match entry.get("numbering_method").and_then(Value::as_str) { + let voucher_type = entry["voucher_type"] + .as_str() + .ok_or_else(|| "argument_invalid:numbering".to_string())? + .to_string(); + let method = match entry["numbering_method"].as_str() { Some("manual") => NumberingMethod::Manual, Some("automatic") => NumberingMethod::Automatic, - Some("unknown") => NumberingMethod::Unknown, - _ => return Err("argument_invalid:numbering".to_string()), + // The schema admits exactly these three, so anything else was + // already refused above. + _ => NumberingMethod::Unknown, }; Ok((voucher_type, method)) }) @@ -276,66 +257,28 @@ fn parse_proposals(args: &Value) -> Result, String> { .get("vouchers") .and_then(Value::as_array) .ok_or_else(|| "vouchers_required".to_string())?; - if proposed.is_empty() || proposed.len() > MAX_PRESENCE_VOUCHERS { - return Err("argument_invalid:vouchers".to_string()); - } + let invalid = || "argument_invalid:vouchers".to_string(); let mut parsed = Vec::with_capacity(proposed.len()); for (position, voucher) in proposed.iter().enumerate() { - only_known_keys( - voucher, - &[ - "date", - "voucher_type", - "voucher_number", - "remote_id", - "party", - "entries", - ], - "vouchers", - )?; - let date = normalized_date( - voucher - .get("date") - .and_then(Value::as_str) - .ok_or_else(|| "argument_invalid:vouchers".to_string())?, - )?; - let name_limit = agent_import::MAX_MASTER_NAME_CHARS; - let voucher_type = nested_text(voucher, "voucher_type", "vouchers", name_limit)? - .ok_or_else(|| "argument_invalid:vouchers".to_string())?; - let voucher_number = nested_text(voucher, "voucher_number", "vouchers", name_limit)?; - let remote_id = nested_text(voucher, "remote_id", "vouchers", name_limit)?; - let party = nested_text(voucher, "party", "vouchers", name_limit)?; - let rows = voucher - .get("entries") - .and_then(Value::as_array) - .filter(|entries| !entries.is_empty() && entries.len() <= MAX_PRESENCE_ENTRIES) - .ok_or_else(|| "argument_invalid:vouchers".to_string())?; - let bounded = rows + let date = normalized_date(voucher["date"].as_str().ok_or_else(invalid)?)?; + let rows = voucher["entries"].as_array().ok_or_else(invalid)?; + let entries = rows .iter() .map(|entry| { - only_known_keys(entry, &["ledger", "amount"], "vouchers")?; - let ledger = nested_text(entry, "ledger", "vouchers", name_limit)? - .ok_or_else(|| "argument_invalid:vouchers".to_string())?; - let amount = nested_text(entry, "amount", "vouchers", MAX_PRESENCE_AMOUNT_CHARS)? - .ok_or_else(|| "argument_invalid:vouchers".to_string())?; - Ok((ledger, amount)) + Ok(ObservedEntry { + ledger: entry["ledger"].as_str().ok_or_else(invalid)?, + amount: entry["amount"].as_str().ok_or_else(invalid)?, + }) }) .collect::, String>>()?; - let entries = bounded - .iter() - .map(|(ledger, amount)| ObservedEntry { - ledger: ledger.as_str(), - amount: amount.as_str(), - }) - .collect::>(); parsed.push( ProposedVoucher::new(ProposedVoucherInput { position, date: &date, - voucher_type: &voucher_type, - voucher_number: voucher_number.as_deref(), - remote_id: remote_id.as_deref(), - party: party.as_deref(), + voucher_type: voucher["voucher_type"].as_str().ok_or_else(invalid)?, + voucher_number: voucher["voucher_number"].as_str(), + remote_id: voucher["remote_id"].as_str(), + party: voucher["party"].as_str(), entries: &entries, }) .map_err(|error| error.safe_reason_code().to_string())?, diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index 337d9e55..969bff77 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -201,6 +201,45 @@ async fn cross_input_refusals_also_cost_no_tally_read() { } } +/// The bound is not restated anywhere, so the test must not restate it either: +/// it reads `maxLength` out of the published schema and proves the boundary +/// tracks it. If the schema moves, this moves with it; if the enforcement stops +/// following the schema, this fails. +#[tokio::test] +async fn nested_bounds_are_read_from_the_schema_rather_than_duplicated() { + let definitions = tool_definitions(true, false); + let schema = definitions + .as_array() + .and_then(|tools| tools.iter().find(|tool| tool["name"] == "voucher_presence")) + .map(|tool| tool["inputSchema"].clone()) + .expect("voucher_presence schema"); + let limit = schema["properties"]["vouchers"]["items"]["properties"]["voucher_number"] + ["maxLength"] + .as_u64() + .expect("a published maxLength") as usize; + let entries = json!([{"ledger":"Cash","amount":"-1.00"},{"ledger":"WR2 Sales","amount":"1.00"}]); + let numbering = json!([{"voucher_type":"Journal","numbering_method":"manual"}]); + let directory = tempfile::tempdir().expect("directory"); + let server = offline_server(directory.path()); + for (length, refused) in [(limit, false), (limit + 1, true)] { + let response = server + .call_tool_response( + "voucher_presence", + json!({"company_guid":GUID,"from":"20260901","to":"20260930","numbering":numbering, + "vouchers":[{"date":"20260901","voucher_type":"Journal", + "voucher_number":"x".repeat(length),"entries":entries}]}), + ) + .await; + let code = &response.value["structuredContent"]["result"]["error"]["code"]; + assert_eq!( + code == "argument_invalid:vouchers", + refused, + "length {length} against a published limit of {limit}" + ); + assert_eq!(response.value["structuredContent"]["evidence"]["bytes"], 0); + } +} + #[tokio::test] async fn nested_arguments_are_bounded_to_the_published_schema() { let directory = tempfile::tempdir().expect("directory"); From 0307e57460698ee808d27b73ec14e2d5d3dc1624 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 09:11:34 +0530 Subject: [PATCH 31/91] Fix five more findings, and correct four ADR claims a peer lane disproved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings, none of which I could invalidate: A source that names no party had no party rule run against it, so an absence rested on date and amount alone — the pair this contract says collides. PartyNotSupplied withholds it now; present by identity is unaffected, and naming the party is what makes absence available again. A collision between two proposals is a fact about the source, and the check sat inside the observed-type guard, so two proposals sharing a number for a type the book has never seen both fell through to absent. Moved out; it does not become less true because the book has not seen the type. A window could declare REMOTEID unread while carrying one. Refused at construction: the contradiction would let a verdict settle on evidence the window says was not gathered. remote_id is no longer an accepted input. The shipped read cannot fetch REMOTEID, so supplying one could only ever withhold a verdict a unique manual number would otherwise settle. Refusing the input beats accepting it and degrading. The response could exceed its byte cap on echoed fields alone, and this shape was invisible to page_shape, so a complete report was discarded after all three Tally reads. It now pages as items/offset/total like every sibling read, which deletes the bespoke candidate budget outright — the existing machinery trims with a resumable cursor instead. Four corrections from the bank-import lane, checked against the tree: The client REMOTEID is not unreadable, only overwritten in the attribute it occupies. 9.8's batch-identity run recorded the REMOTEID and the narration marker sharing one batch-derived UUID, and render_agent_vouchers already fetches NARRATION — so the key survives in the field Tally does not own, and a narration marker is a reachable identity basis today with no change to a qualified read profile. Recorded as the named next step rather than built here. 3.3a's upsert is evidence for a byte-identical repeat only; its untested list names a differing payload. The duplicate residual is therefore hand-keyed vouchers plus any re-send whose content changed. 9.7's operation matrix, including the Delete row the correction path rests on, is an Edit Log 7.0 Educational baseline — the least unverified correction, not a confirmed one. And a fingerprint is never promoted to identity here, which is the rule agent_import.rs already enforces; stated explicitly because the hazard is identical. 868 lib and 180 core green, clippy and fmt clean, gate passing at 213 pins with none removed. Co-Authored-By: Claude Opus 5 --- docs/adr/0017-voucher-presence-authority.md | 53 +++++--- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/book_presence.rs | 119 +++++++++++------- .../src/book_presence_tests.rs | 119 ++++++++++++++++++ src-tauri/src/agent_catalog.rs | 3 +- src-tauri/src/agent_presence.rs | 70 +++++------ src-tauri/src/agent_presence_tests.rs | 60 ++++++++- 7 files changed, 317 insertions(+), 111 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 82755a12..64d7a1ac 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -57,7 +57,7 @@ the contract that says what a comparison is allowed to conclude. | Candidate key | Where it holds | Where it fails | | --- | --- | --- | -| `REMOTEID` | A voucher whose Tally-assigned value the caller has already observed. | The **client** key is not readable back at all: §3.3a verified that Tally overwrites the attribute with its own value, so a key Bridge wrote can never be matched against a later read. Whether a voucher keyed by hand in the Tally UI carries a Tally-assigned value is **untested in either direction** — assuming it does not would be as unfounded as assuming it does. | +| `REMOTEID` | A voucher whose Tally-assigned value the caller has already observed. | The **attribute** does not carry the client's key back: §3.3a verified Tally overwrites it with its own value. It does not follow that the key is unreadable — §9.8's batch-identity run recorded the `REMOTEID` and the **narration marker** sharing one batch-derived UUID, so it survives in the field Tally does not own. Whether a voucher keyed by hand in the Tally UI carries a Tally-assigned value is **untested in either direction**. | | `VOUCHERNUMBER` | Voucher types numbered **Manual**. One book preserved a long alphanumeric invoice series verbatim, another a plain three-digit bill number. | Under **Automatic** numbering Tally *discards* the supplied number (§9.8), so a number-based key is silently ineffective. And a book that does not set `PREVENTDUPLICATES` can hold the same number twice — one did, twenty-five times. | | date + party + amount | Needs neither of the above. | Collides. In one month of real data `141,600`, `177,000` and `16,992` each recurred across *unrelated* parties. | @@ -194,9 +194,8 @@ Two bases, and nothing else: - **`RemoteId`** — the proposal and exactly one book voucher carry the same `REMOTEID`, and no other proposal carries it. Note carefully what a caller may put there: **not** the client key it wrote on a previous import, which - §3.3a verified is overwritten and unreadable, but a Tally-assigned value it - has previously read back. A caller that supplies its own write key here will - match nothing and be told `absent` — correctly, and uselessly. + §3.3a verified Tally overwrites in the attribute, but a Tally-assigned value + it has previously read back. - **`ManualVoucherNumber`** — the voucher type is declared `Manual`, and the (voucher type, normalized number) pair selects **exactly one book voucher and exactly one proposal**. Uniqueness on both sides is ADR 0016's rule 2, and it @@ -297,16 +296,19 @@ severity: Tally, not in the return, not in Bridge, and not in any exceptions report. There is no artifact to find later. - A false `Absent` on a voucher **Bridge previously imported** creates nothing - at all: the same client `REMOTEID` upserts (§3.3a). The duplicate risk is - confined to vouchers an operator keyed by hand — which is the real residual, - and was both blocked engagements, but it is a smaller set than "everything". + at all *when the re-sent payload is byte-identical* — that is the case §3.3a + measured, and its own untested list names "when the payload differs from the + original". So the duplicate risk is confined to vouchers an operator keyed by + hand **plus** any re-send whose content has changed: smaller than + "everything", and larger than "hand-keyed only". - A false `Absent` on a hand-keyed voucher does create a duplicate, and that duplicate is **visible and correctable**: Tally's own `Duplicate Voucher No.` exceptions report surfaces it, and re-importing under the same client - `REMOTEID` overwrites the earlier row (§3.3a's correction path), which is the - only correction Tally offers since vouchers cannot be modified (§9.7). Note - the mechanism precisely — correction works by *re-import*, not by reading the - key back, because §3.3a verified the client key is not readable at all. + `REMOTEID` overwrites the earlier row (§3.3a's correction path) — the **least + unverified** correction available rather than a confirmed one, since §9.7's + operation matrix and the Delete row it rests on were measured on an Edit Log + 7.0 Educational baseline and are not qualified on a licensed profile. The + mechanism is *re-import*, not reading the key back out of the attribute. **Therefore the bar for `Present` is set higher than the bar for `Absent`, and both are set higher than a resemblance.** `Present` requires identity; @@ -410,13 +412,28 @@ human-approved batch — this ADR does not move. `PossiblyPresent` would misrepresent the capability. The crate is shared, and the desktop consumes the same function once a draft row carries a number and a party. -- **`RemoteId` is contract-complete and not reachable from the shipped read.** - `render_agent_vouchers` does not `FETCH` `REMOTEID`; only the AlterID change - feed does. Adding it changes a qualified read profile and needs its own live - evidence, so it is not done here. The shipped adapter therefore declares - `RemoteIdEvidence::NotRead`, which makes the gap structural rather than - advisory: a proposal that supplies a `REMOTEID` is withheld from `Absent` - instead of being judged on the keys that happen to remain. Both motivating engagements were hand-keyed +- **`RemoteId` is contract-complete and not reachable from the shipped read**, + so the adapter declares `RemoteIdEvidence::NotRead` and the tool's schema + does not accept a `remote_id` at all. `render_agent_vouchers` does not + `FETCH REMOTEID`; only the AlterID change feed does. Accepting an input that + could only ever *withhold* a verdict would be worse than refusing it. +- **The identity channel that does survive a round trip is the narration, and + this read already fetches it.** §9.8's batch-identity run recorded the + `REMOTEID` and the narration marker sharing one batch-derived UUID: Tally + overwrites the field it owns and leaves alone the field it does not, and + `render_agent_vouchers` fetches `NARRATION`. A marker a generator writes into + the narration is therefore readable back **today**, with no change to a + qualified read profile — which makes it the named path to a reachable + identity basis for vouchers Bridge itself wrote. Deliberately not built here: + a new basis is its own change, and this contract is under review. It is the + first thing to build on top of it. +- **A content fingerprint is never promoted to identity, which is the rule + `agent_import.rs` already enforces.** There a fingerprint-only match is + `matching_content_observed` and `posted_verified` needs a narration-tagged + match. Here date, party and amount can only ever produce candidates. The + hazard is identical in both: a company with a recurring same-day payment + already holds a voucher with that tuple, so the tuple would let a pre-existing + voucher stand in for one that was never written. Both motivating engagements were hand-keyed and would not have had one regardless. - The window is read in full before any comparison; `vouchers`' own pagination bounds output, not Tally's work. A window past `MAX_WINDOW_VOUCHERS` is diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 8fb9d107..94a0a56e 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "7c7c8699f0e774e7999287a9ad392ccb329ebd1bd7614cb36b245d8a0a2f4c17" + "sha256": "e63b4895c1e840afeb07fff8155e4553e629f37f50b9b0b925c8f1f00dfb28b0" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/agent_presence.rs", - "sha256": "e318fe6a11807e3191ac36f7639e12125753b5f2c1a4b6d347e1779986ac7234" + "sha256": "55aafa85117ec701f932a590b00d36913f8e5ced668a5265ae2e31169c4b4cb8" }, { "path": "src-tauri/src/agent_read_profiles.rs", diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 9ded173e..7d257f39 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -66,6 +66,11 @@ pub enum PresenceError { WindowVoucherOutsideRange, #[error("book window carried the same voucher key twice")] WindowDuplicateVoucherKey, + /// A window declaring that `REMOTEID` was never read, carrying vouchers + /// that have one. The two statements contradict, and the contradiction + /// would let a verdict settle on evidence the window says was not gathered. + #[error("book window declared REMOTEID unread while carrying one")] + WindowRemoteIdContradiction, /// A proposal dated outside the window would be judged against evidence /// that could not contain it. #[error("book window does not cover every proposed date")] @@ -108,6 +113,7 @@ impl PresenceError { Self::WindowTooLarge => "presence_window_too_large", Self::WindowVoucherOutsideRange => "presence_window_voucher_outside_range", Self::WindowDuplicateVoucherKey => "presence_window_duplicate_voucher_key", + Self::WindowRemoteIdContradiction => "presence_window_remote_id_contradiction", Self::WindowDoesNotCover => "presence_window_does_not_cover", Self::ProposalsEmpty => "presence_proposals_empty", Self::TooManyProposals => "presence_proposals_too_many", @@ -376,6 +382,9 @@ impl BookWindow { if !keys.insert(voucher.key()) { return Err(PresenceError::WindowDuplicateVoucherKey); } + if remote_id_evidence == RemoteIdEvidence::NotRead && voucher.remote_id.is_some() { + return Err(PresenceError::WindowRemoteIdContradiction); + } } Ok(Self { from, @@ -536,6 +545,12 @@ pub enum UndecidedReason { /// The party comparison could not be completed, so no rule that needs a /// party actually ran and `Absent` is not available. PartyNotDecidable, + /// The source named no party at all. Nothing was skipped — but nothing was + /// compared either, and a book voucher for the same party and amount on + /// another date would never have surfaced. `Present` is still reachable by + /// identity; only the absence claim is withheld, and supplying the party + /// is what makes it available. + PartyNotSupplied, /// Two proposals both resolved to the same book voucher, possibly by /// different identity bases. One book voucher can satisfy at most one /// proposal, so every claimant is demoted rather than one being chosen. @@ -562,6 +577,7 @@ impl UndecidedReason { Self::VoucherTypeNotObserved => "presence_voucher_type_not_observed", Self::ResemblesBookVoucher => "presence_resembles_book_voucher", Self::PartyNotDecidable => "presence_party_not_decidable", + Self::PartyNotSupplied => "presence_party_not_supplied", Self::BookVoucherClaimedTwice => "presence_book_voucher_claimed_twice", Self::IdentityConflict => "presence_identity_conflict", Self::RemoteIdEvidenceUnavailable => "presence_remote_id_evidence_unavailable", @@ -845,7 +861,10 @@ fn bind_parties( None => PartyResolution { outcome: PartyOutcome::NotSupplied, compare_keys: BTreeSet::new(), - incomplete: false, + // No party was skipped, and none was compared. The party rules + // could not run at all, so an absence rests on the date and + // amount alone — which is the pair this contract says collides. + incomplete: true, }, Some(name) => resolved .get(name) @@ -872,7 +891,7 @@ fn resolution_of(binding: &master_binding::EntityBinding) -> PartyResolution { match &unresolved.candidates { // Nothing resembles the party, and that is information. Candidates::None => (BTreeSet::new(), false), - Candidates::Listed(listed) => (keys(listed), false), + Candidates::Listed { listed } => (keys(listed), false), // Names exist that were never compared, either way. Candidates::Truncated { listed, .. } => (keys(listed), true), Candidates::Withheld { .. } => (BTreeSet::new(), true), @@ -1190,14 +1209,16 @@ fn decide( } } - if method == NumberingMethod::Manual && type_observed { + // A collision between two proposals is a fact about the *source*. It does + // not become less true because the book has never seen this voucher type, + // so it is settled before the observed-type guard rather than inside it. + if method == NumberingMethod::Manual { if let Some(number_key) = proposal.number_key.as_deref() { let proposed_twice = proposal_number_counts .get(&(proposal.type_key.as_str(), number_key)) .copied() .unwrap_or_default() > 1; - let touched = number_matches.iter().copied().collect::>(); if proposed_twice { return shell( PresenceStatus::PossiblyPresent(undecided( @@ -1208,13 +1229,38 @@ fn decide( CandidateRule::SharedVoucherNumber, ), )), - touched, + number_matches.iter().copied().collect(), ); } - if number_matches.len() > 1 { + } + } + + // Manual numbering only decides *within* an observed voucher type: numbers + // are a per-type series, so a cross-type match is a resemblance. + if method == NumberingMethod::Manual && type_observed && proposal.number_key.is_some() { + let touched = number_matches.iter().copied().collect::>(); + if number_matches.len() > 1 { + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::BookNumberCollision, + candidates_from(window, &number_matches, CandidateRule::SharedVoucherNumber), + )), + touched, + ); + } + if number_matches.len() == 1 { + let matched = &window.vouchers[number_matches[0]]; + // Two identity signals that disagree are reported, never + // settled in the number's favour — the same rule ADR 0016 + // applies to an identifier contradicting an exact name. + let contradicted = match (proposal.remote_id.as_deref(), matched.remote_id.as_deref()) { + (Some(proposed), Some(observed)) => proposed != observed, + _ => false, + }; + if remote_id_unverifiable { return shell( PresenceStatus::PossiblyPresent(undecided( - UndecidedReason::BookNumberCollision, + UndecidedReason::RemoteIdEvidenceUnavailable, candidates_from( window, &number_matches, @@ -1224,47 +1270,23 @@ fn decide( touched, ); } - if number_matches.len() == 1 { - let matched = &window.vouchers[number_matches[0]]; - // Two identity signals that disagree are reported, never - // settled in the number's favour — the same rule ADR 0016 - // applies to an identifier contradicting an exact name. - let contradicted = - match (proposal.remote_id.as_deref(), matched.remote_id.as_deref()) { - (Some(proposed), Some(observed)) => proposed != observed, - _ => false, - }; - if remote_id_unverifiable { - return shell( - PresenceStatus::PossiblyPresent(undecided( - UndecidedReason::RemoteIdEvidenceUnavailable, - candidates_from( - window, - &number_matches, - CandidateRule::SharedVoucherNumber, - ), - )), - touched, - ); - } - if contradicted { - return shell( - PresenceStatus::PossiblyPresent(undecided( - UndecidedReason::IdentityConflict, - candidates_from( - window, - &number_matches, - CandidateRule::SharedVoucherNumber, - ), - )), - touched, - ); - } + if contradicted { return shell( - settled(proposal, party, matched, PresenceBasis::ManualVoucherNumber), + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::IdentityConflict, + candidates_from( + window, + &number_matches, + CandidateRule::SharedVoucherNumber, + ), + )), touched, ); } + return shell( + settled(proposal, party, matched, PresenceBasis::ManualVoucherNumber), + touched, + ); } } @@ -1314,11 +1336,12 @@ fn decide( ); } if party.incomplete { + let reason = match party.outcome { + PartyOutcome::NotSupplied => UndecidedReason::PartyNotSupplied, + _ => UndecidedReason::PartyNotDecidable, + }; return shell( - PresenceStatus::PossiblyPresent(undecided( - UndecidedReason::PartyNotDecidable, - Vec::new(), - )), + PresenceStatus::PossiblyPresent(undecided(reason, Vec::new())), BTreeSet::new(), ); } diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 471972d2..24cea4a9 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1769,3 +1769,122 @@ fn a_remote_id_and_a_number_agreeing_on_one_voucher_still_settle() { ); assert_eq!(only(&report).present_book_key(), Some("book-1")); } + +/// A source that names no party had no party rule run against it, so an +/// absence rests on date and amount alone — the pair this contract says +/// collides. `Present` by identity is unaffected; only the absence is +/// withheld, and supplying the party is what makes it available again. +#[test] +fn a_proposal_that_names_no_party_cannot_be_reported_absent() { + let window = window(&[BookRow::new("book-1", "20260819", "AA0130").party("Bravo Industries")]); + let mut proposal = ProposalRow::new(0, "20260812", "AA0777"); + proposal.party = None; + proposal.rows = vec![["Charlie Minerals", "-55.00"], ["Sales Account", "55.00"]]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &[proposal.build()], + ); + let entry = only(&report); + assert_eq!(entry.party, PartyOutcome::NotSupplied); + assert!(!entry.is_absent()); + assert_eq!(reason(entry), UndecidedReason::PartyNotSupplied); +} + +#[test] +fn naming_the_party_is_what_makes_absence_available() { + let window = window(&[BookRow::new("book-1", "20260819", "AA0130").party("Bravo Industries")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0777") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-55.00"], + ["Sales Account", "55.00"], + ]) + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert!(only(&report).is_absent()); +} + +#[test] +fn a_proposal_that_names_no_party_still_settles_by_identity() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let mut proposal = ProposalRow::new(0, "20260812", "AA0118"); + proposal.party = None; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &[proposal.build()], + ); + assert_eq!(only(&report).present_book_key(), Some("book-1")); +} + +/// A collision between two proposals is a fact about the source. It does not +/// become less true because the book has never seen that voucher type. +#[test] +fn proposals_sharing_a_number_collide_even_for_an_unobserved_type() { + let window = window(&[BookRow::new("book-1", "20260819", "AA0130").party("Bravo Industries")]); + let declaration = + NumberingDeclaration::new([("Part Sale", NumberingMethod::Manual)]).expect("numbering"); + let money = vec![["Charlie Minerals", "-61.00"], ["Sales Account", "61.00"]]; + let proposals = [ + ProposalRow::new(0, "20260812", "AA0801") + .voucher_type("Part Sale") + .party("Charlie Minerals") + .rows(money.clone()) + .build(), + ProposalRow::new(1, "20260812", "AA0801") + .voucher_type("Part Sale") + .party("Charlie Minerals") + .rows(money) + .build(), + ]; + let report = run(&window, &catalog(), &declaration, &proposals); + assert_eq!( + report.totals().absent, + 0, + "colliding rows are not safe to import" + ); + for entry in report.vouchers() { + assert!(!entry.voucher_type_observed); + assert_eq!(reason(entry), UndecidedReason::ProposalNumberCollision); + } +} + +/// A window cannot say "REMOTEID was never read" while carrying one. The two +/// statements contradict, and the contradiction would let a verdict settle on +/// evidence the window itself says was not gathered. +#[test] +fn a_window_declaring_remote_ids_unread_refuses_to_carry_one() { + let carrying = vec![BookRow::new("book-1", "20260812", "AA0118") + .remote_id("tally-1") + .build()]; + assert_eq!( + BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::NotRead, + carrying, + ) + .expect_err("contradiction"), + PresenceError::WindowRemoteIdContradiction + ); + // The same vouchers are fine once the window admits it read the column. + assert!(BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + vec![BookRow::new("book-1", "20260812", "AA0118") + .remote_id("tally-1") + .build()], + ) + .is_ok()); +} diff --git a/src-tauri/src/agent_catalog.rs b/src-tauri/src/agent_catalog.rs index beac8334..76215ee2 100644 --- a/src-tauri/src/agent_catalog.rs +++ b/src-tauri/src/agent_catalog.rs @@ -274,6 +274,8 @@ pub(super) fn registered_tool_definitions(import_enabled: bool, writes_enabled: "Answer which of 1\u{2013}500 proposed vouchers are already in the book, over one literal window. Tally dedupes on one key only: re-sending a voucher under the same VOUCHERNUMBER creates a second one, while a client-supplied REMOTEID upserts instead. A voucher keyed by hand carries no client REMOTEID, so it is the one at duplication risk. `presence` is present, possibly_present or absent, and only `present` names a book voucher. Only identity decides: a shared REMOTEID, or a voucher number on a voucher type you declare `manual` \u{2014} unique on both sides, within an observed voucher type, and never onto a cancelled or optional voucher. Date, party and amount only ever produce candidates, with the rule that surfaced each and no ranking or score. Every voucher type a proposal names needs a declared numbering method; under `automatic` Tally discards the supplied number, so nothing can be decided from it. `absent` means absent from this window, so cover the dates the book could hold. Reads the full window before comparing; dense windows can fail source limits. Party names bind through the same rules as validate_masters. A reported difference on a `present` voucher is a finding for a person, not a work item: correcting a voucher by Alter or Cancel silently creates a duplicate instead (\u{00a7}9.7), and no Bridge path can correct a voucher it did not write. This never dispatches import XML to Tally.", json!({"type":"object","additionalProperties":false,"required":["company_guid","from","to","numbering","vouchers"],"properties":{ "company_guid":{"type":"string","minLength":1}, + "offset":{"type":"integer","minimum":0,"default":0}, + "limit":{"type":"integer","minimum":1,"default":500}, "from":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"}, "to":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"}, "numbering":{"type":"array","minItems":1,"maxItems":presence::MAX_PRESENCE_VOUCHER_TYPES,"items":{"type":"object","additionalProperties":false,"required":["voucher_type","numbering_method"],"properties":{"voucher_type":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"},"numbering_method":{"type":"string","enum":["manual","automatic","unknown"]}}}}, @@ -281,7 +283,6 @@ pub(super) fn registered_tool_definitions(import_enabled: bool, writes_enabled: "date":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"}, "voucher_type":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"}, "voucher_number":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"}, - "remote_id":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"}, "party":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"}, "entries":{"type":"array","minItems":1,"maxItems":presence::MAX_PRESENCE_ENTRIES,"items":{"type":"object","additionalProperties":false,"required":["ledger","amount"],"properties":{"ledger":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"},"amount":{"type":"string","minLength":1,"maxLength":64,"pattern":r"\S"}}}} }}} diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs index 62925ffd..5faf83ab 100644 --- a/src-tauri/src/agent_presence.rs +++ b/src-tauri/src/agent_presence.rs @@ -22,17 +22,6 @@ pub(super) const MAX_PRESENCE_VOUCHERS: usize = 500; pub(super) const MAX_PRESENCE_VOUCHER_TYPES: usize = 50; /// Most ledger entries one proposed voucher may carry. pub(super) const MAX_PRESENCE_ENTRIES: usize = 200; -/// Candidates this response may carry in total, across every proposal. -/// -/// The per-proposal cap alone does not bound the response, and this result -/// shape is deliberately **not** pageable — `page_shape` cannot trim it, so an -/// over-large report is replaced wholesale by `agent_response_too_large` -/// *after* every Tally read has been paid for. An aggregate budget keeps an -/// admitted request retrievable. Spending it in proposal order, and marking -/// what it cut, is the same discipline `master_binding` applies to its own -/// report. -const MAX_PRESENCE_RESPONSE_CANDIDATES: usize = 1_000; - /// Enforces the published `inputSchema` on this tool's nested arrays. /// /// The shared argument validator stops at the outer selectors, and the core @@ -66,6 +55,9 @@ impl Server { // Parse the caller's own input before any Tally read: a malformed // proposal set should never cost a read. enforce_published_schema(args)?; + let offset = arg_usize(args, "offset", 0)?; + let limit = + arg_positive_usize(args, "limit", self.settings.max_rows)?.min(self.settings.max_rows); let numbering = parse_numbering(args)?; let proposals = parse_proposals(args)?; // Both remaining cross-input refusals depend only on the arguments, so @@ -165,17 +157,18 @@ impl Server { let request = PresenceRequest::new(&window, &catalog, &numbering, &proposals) .map_err(presence_code)?; let report = book_presence::assess(&request); + let (result, truncated) = presence_result(&report, &catalogue, reason, offset, limit); Ok(ToolOutcome { payload: json!({ "company": company_json(&company, std::slice::from_ref(&company)), - "result": presence_result(&report, &catalogue, reason), + "result": result, }), evidence: accumulated .clone() .expect("presence evidence is present after admitted reads"), company_guid: Some(guid.to_string()), - truncated: false, + truncated, }) } .await; @@ -277,7 +270,10 @@ fn parse_proposals(args: &Value) -> Result, String> { date: &date, voucher_type: voucher["voucher_type"].as_str().ok_or_else(invalid)?, voucher_number: voucher["voucher_number"].as_str(), - remote_id: voucher["remote_id"].as_str(), + // Not an accepted input: the shipped read cannot fetch + // REMOTEID, so a supplied one could only ever withhold a + // verdict. The crate keeps the basis for callers that can. + remote_id: None, party: voucher["party"].as_str(), entries: &entries, }) @@ -291,43 +287,37 @@ fn presence_result( report: &PresenceReport, catalogue: &[String], corroboration_reason: Option<&'static str>, -) -> Value { + offset: usize, + limit: usize, +) -> (Value, bool) { let (from, to) = report.window(); - let mut budget = MAX_PRESENCE_RESPONSE_CANDIDATES; - let vouchers = report + let total = report.vouchers().len(); + // Paged like every other read in this adapter, for one reason beyond + // consistency: this result shape is otherwise invisible to `page_shape`, + // so an over-large report would be discarded wholesale *after* all three + // Tally reads were paid for. An `items` array with an `offset` is the + // shape the response machinery can trim with a resumable cursor. + let items = report .vouchers() .iter() - .map(|entry| { - let mut value = - mark_presence_party_names(serde_json::to_value(entry).unwrap_or_default()); - // A trimmed list keeps its true count and says it was cut, so an - // empty list here still never reads as "nothing resembles this". - if let Some(candidates) = value.get_mut("candidates").and_then(Value::as_array_mut) { - if candidates.len() > budget { - candidates.truncate(budget); - value["candidates_truncated"] = Value::Bool(true); - } - let spent = value["candidates"] - .as_array() - .map(Vec::len) - .unwrap_or_default(); - budget = budget.saturating_sub(spent); - } - value - }) + .skip(offset) + .take(limit) + .map(|entry| mark_presence_party_names(serde_json::to_value(entry).unwrap_or_default())) .collect::>(); - let candidate_budget_exhausted = budget == 0; - json!({ + let truncated = offset.saturating_add(items.len()) < total; + let result = json!({ "profile": "agent_voucher_presence_v1", // Every verdict is relative to this window. `absent` means absent from // this range and never absent from the book. "window": {"from": from, "to": to, "read": "complete", "reason": corroboration_reason}, - "vouchers": vouchers, - "candidate_budget_exhausted": candidate_budget_exhausted, + "items": items, + "offset": offset, + "total": total, "totals": report.totals(), "book": report.observations(), "catalogue_evidence_sha256": sha256_json(&catalogue.to_vec()), - }) + }); + (result, truncated) } /// Marks the names an egress policy treats as party data. Voucher numbers and diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index 969bff77..f0ccab44 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -155,6 +155,59 @@ fn the_published_schema_names_the_three_numbering_methods_and_its_bounds() { assert!(tool.get("annotations").is_none()); } +/// `remote_id` is no longer an accepted input: the shipped read cannot fetch +/// `REMOTEID`, so supplying one could only ever withhold a verdict that a +/// unique manual number would otherwise settle. Refusing the input is more +/// honest than accepting it and degrading. +#[tokio::test] +async fn a_remote_id_is_not_an_accepted_input_at_this_surface() { + let definitions = tool_definitions(true, false); + let schema = definitions + .as_array() + .and_then(|tools| tools.iter().find(|tool| tool["name"] == "voucher_presence")) + .expect("voucher_presence schema")["inputSchema"] + .clone(); + assert!(schema["properties"]["vouchers"]["items"]["properties"] + .get("remote_id") + .is_none()); + let directory = tempfile::tempdir().expect("directory"); + let server = offline_server(directory.path()); + let response = server + .call_tool_response( + "voucher_presence", + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[{"date":"20260901","voucher_type":"Journal","remote_id":"tally-1", + "entries":[{"ledger":"Cash","amount":"-1.00"},{"ledger":"WR2 Sales","amount":"1.00"}]}]}), + ) + .await; + assert_eq!( + response.value["structuredContent"]["result"]["error"]["code"], + "argument_invalid:vouchers" + ); + assert_eq!(response.value["structuredContent"]["evidence"]["bytes"], 0); +} + +#[test] +fn the_result_is_pageable_so_an_over_large_report_is_not_discarded() { + // `page_shape` recognises `items` with an `offset`; without that this + // shape is untrimmable and a complete report is replaced wholesale by + // `agent_response_too_large` after every Tally read has been paid for. + let mut structured = json!({"result":{"offset":0,"total":3,"items": + (0..3).map(|id| json!({"position":id,"padding":"x".repeat(256)})).collect::>()}}); + let (bounded, trimmed, _) = + enforce_response_byte_cap(structured.clone(), 400).expect("trims rather than refusing"); + assert!(trimmed); + let kept = bounded["result"]["items"].as_array().expect("items"); + assert!(!kept.is_empty() && kept.len() < 3); + assert_eq!(bounded["result"]["next_offset"], kept.len()); + // And an untrimmed report keeps every row and offers no cursor. + structured["result"]["items"] = json!([{"position":0}]); + let (complete, trimmed, _) = enforce_response_byte_cap(structured, 10_000).expect("fits"); + assert!(!trimmed); + assert!(complete["result"].get("next_offset").is_none()); +} + #[test] fn an_unknown_numbering_method_is_refused_at_the_published_schema() { assert_eq!( @@ -217,7 +270,8 @@ async fn nested_bounds_are_read_from_the_schema_rather_than_duplicated() { ["maxLength"] .as_u64() .expect("a published maxLength") as usize; - let entries = json!([{"ledger":"Cash","amount":"-1.00"},{"ledger":"WR2 Sales","amount":"1.00"}]); + let entries = + json!([{"ledger":"Cash","amount":"-1.00"},{"ledger":"WR2 Sales","amount":"1.00"}]); let numbering = json!([{"voucher_type":"Journal","numbering_method":"manual"}]); let directory = tempfile::tempdir().expect("directory"); let server = offline_server(directory.path()); @@ -502,12 +556,14 @@ async fn a_live_shaped_cycle_separates_present_undecided_and_absent() { assert_eq!(result["profile"], "agent_voucher_presence_v1"); assert_eq!(result["window"]["from"], "20260901"); assert_eq!(result["window"]["to"], "20260930"); + assert_eq!(result["total"], 3); + assert_eq!(result["offset"], 0); assert_eq!(result["totals"]["requested"], 3); assert_eq!(result["totals"]["present"], 2); assert_eq!(result["totals"]["absent"], 1); assert_eq!(result["totals"]["possibly_present"], 0); - let vouchers = result["vouchers"].as_array().expect("vouchers"); + let vouchers = result["items"].as_array().expect("items"); assert_eq!(vouchers[0]["presence"], "present"); assert_eq!(vouchers[0]["basis"], "manual_voucher_number"); assert_eq!(vouchers[0]["book_key"], format!("{CAPTURED_GUID}-00000001")); From 1ade8ce63763c922384158cb8dcb51200f17b4a9 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 09:17:07 +0530 Subject: [PATCH 32/91] Reseal after rebasing onto the current binding head Generated artifacts only: the surface and matrix taken from the base wholesale, the two new pins re-added, and the seal sequence re-run. Never hand-merged. Co-Authored-By: Claude Opus 5 --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 59f3d563..3c00ffd4 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": "468c5b38d5300be99b9e0761e929fbecba69ea89b505e35396b8519a129c3a7d", + "compatibility_surface_sha256": "2c7ed78fa5161b1ff6610ee6d2fa27e5f1d29d47809716ec704a010f1290e8fe", "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 94a0a56e..816b7e5a 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", - "sha256": "58674602eb3131c101ace7638d43cf550d74b29eb3848b0c908687bb2c9fbf9c" + "sha256": "7367c3c94f7412e1126b117239636a38678189ec89369c0d3494bd553d8e4e4b" }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", @@ -327,7 +327,7 @@ }, { "path": "src-tauri/src/agent.rs", - "sha256": "83824bf04e0d6b10b2cc3ed852a1600ef9472fe37966c6dce4d318efe58f1e9b" + "sha256": "e92609482da823a1c64974ed4995a67bed4ea946b323774b524b8def35ec7b52" }, { "path": "src-tauri/src/agent_desktop_journal.rs", @@ -355,7 +355,7 @@ }, { "path": "src-tauri/src/agent_tests.rs", - "sha256": "107348f522adce1386d767f86cd1390d86479d43b592579b930603364aa8c8b4" + "sha256": "310b65fb872147335baa6e7ad92bc54bc5042e39aad5db70f3f4b7d37b921597" }, { "path": "src-tauri/src/agent_trial_balance.rs", @@ -819,7 +819,7 @@ }, { "path": "tools/bridge-tally-compatibility/src/lib.rs", - "sha256": "8b07e99c7335d6207664302aea4fd800ed524201c8c50d1c7edcf02982d1bce1" + "sha256": "131cd60600693204faf3889d0b6b6c97ca72cc8381554e9bac9a5ae8d63d839a" }, { "path": "tools/bridge-tally-compatibility/src/main.rs", @@ -854,5 +854,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "468c5b38d5300be99b9e0761e929fbecba69ea89b505e35396b8519a129c3a7d" + "manifest_sha256": "2c7ed78fa5161b1ff6610ee6d2fa27e5f1d29d47809716ec704a010f1290e8fe" } \ No newline at end of file From 28625fdbcb9f9cc8d942b04b968a66c293bcaebc Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 09:23:25 +0530 Subject: [PATCH 33/91] Cap the candidate list before cloning it, not after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dense window can hold thousands of vouchers sharing one manual number, and every one of them was cloned into a PresenceCandidate before the response cap discarded all but twenty-five. At the admitted bounds — 500 proposals against a 20,000-voucher window — that is millions of string clones to produce a bounded answer. candidates_from now applies the cap before the clone and returns the true count alongside the retained prefix, so candidate_count is carried rather than recovered from the vector's length. The resemblance path sorts (position, rule) pairs, which need no allocation, and clones a key only for the entries it will keep. No behaviour change: the same candidates in the same order, the same counts, the same truncation flags. A test at 200 colliding vouchers pins the true count against the capped list, and asserts none of them is reported as a voucher no proposal reached. 181 core and 868 lib green, clippy and fmt clean, gate passing. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/book_presence.rs | 85 ++++++++++++------- .../src/book_presence_tests.rs | 37 ++++++++ 4 files changed, 93 insertions(+), 35 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 3c00ffd4..772729e4 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": "2c7ed78fa5161b1ff6610ee6d2fa27e5f1d29d47809716ec704a010f1290e8fe", + "compatibility_surface_sha256": "44ef021bab8f455658602147825da431fa72b1912064e0dfee0b93581742a717", "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 816b7e5a..488c0142 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "e63b4895c1e840afeb07fff8155e4553e629f37f50b9b0b925c8f1f00dfb28b0" + "sha256": "66e9d2f52944856a4e3c962c264ac21911de4bdaff0536cf3fa3ac70b8a8b303" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -854,5 +854,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "2c7ed78fa5161b1ff6610ee6d2fa27e5f1d29d47809716ec704a010f1290e8fe" + "manifest_sha256": "44ef021bab8f455658602147825da431fa72b1912064e0dfee0b93581742a717" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 7d257f39..7a5b7809 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -1068,7 +1068,7 @@ pub fn assess(request: &PresenceRequest<'_>) -> PresenceReport { }; entry.status = PresenceStatus::PossiblyPresent(undecided( UndecidedReason::BookVoucherClaimedTwice, - vec![PresenceCandidate { book_key, rule }], + (vec![PresenceCandidate { book_key, rule }], 1), )); } } @@ -1174,17 +1174,20 @@ fn decide( if number_selects_another { let mut touched = BTreeSet::from([matches[0]]); touched.insert(number_matches[0]); - let mut candidates = + let (mut candidates, mut found) = candidates_from(window, matches, CandidateRule::SharedRemoteId); - candidates.extend(candidates_from( + let (by_number, number_found) = candidates_from( window, &number_matches, CandidateRule::SharedVoucherNumber, - )); + ); + candidates.extend(by_number); + candidates.truncate(MAX_CANDIDATES_PER_PROPOSAL); + found += number_found; return shell( PresenceStatus::PossiblyPresent(undecided( UndecidedReason::IdentityConflict, - candidates, + (candidates, found), )), touched, ); @@ -1330,7 +1333,7 @@ fn decide( return shell( PresenceStatus::PossiblyPresent(undecided( UndecidedReason::RemoteIdEvidenceUnavailable, - Vec::new(), + (Vec::new(), 0), )), BTreeSet::new(), ); @@ -1341,7 +1344,7 @@ fn decide( _ => UndecidedReason::PartyNotDecidable, }; return shell( - PresenceStatus::PossiblyPresent(undecided(reason, Vec::new())), + PresenceStatus::PossiblyPresent(undecided(reason, (Vec::new(), 0))), BTreeSet::new(), ); } @@ -1358,21 +1361,27 @@ fn decide( _ => UndecidedReason::ResemblesBookVoucher, }; let touched = found.keys().copied().collect::>(); - let mut candidates = found + // Ordered as (position, rule) pairs before anything is cloned: the order is + // rule-then-key and only the retained prefix needs a key at all. + let mut ordered = found.into_iter().collect::>(); + ordered.sort_by(|(left_position, left_rule), (right_position, right_rule)| { + left_rule.rank().cmp(&right_rule.rank()).then_with(|| { + window.vouchers[*left_position] + .key() + .cmp(window.vouchers[*right_position].key()) + }) + }); + let total = ordered.len(); + let candidates = ordered .into_iter() + .take(MAX_CANDIDATES_PER_PROPOSAL) .map(|(position, rule)| PresenceCandidate { book_key: window.vouchers[position].key().to_string(), rule, }) .collect::>(); - candidates.sort_by(|left, right| { - left.rule - .rank() - .cmp(&right.rule.rank()) - .then_with(|| left.book_key.cmp(&right.book_key)) - }); shell( - PresenceStatus::PossiblyPresent(undecided(reason, candidates)), + PresenceStatus::PossiblyPresent(undecided(reason, (candidates, total))), touched, ) } @@ -1388,13 +1397,16 @@ fn settled( if voucher.posting != PostingState::Posted { return PresenceStatus::PossiblyPresent(undecided( UndecidedReason::MatchedVoucherNotPosted, - vec![PresenceCandidate { - book_key: voucher.key().to_string(), - rule: match basis { - PresenceBasis::RemoteId => CandidateRule::SharedRemoteId, - PresenceBasis::ManualVoucherNumber => CandidateRule::SharedVoucherNumber, - }, - }], + ( + vec![PresenceCandidate { + book_key: voucher.key().to_string(), + rule: match basis { + PresenceBasis::RemoteId => CandidateRule::SharedRemoteId, + PresenceBasis::ManualVoucherNumber => CandidateRule::SharedVoucherNumber, + }, + }], + 1, + ), )); } PresenceStatus::Present { @@ -1524,30 +1536,39 @@ fn keep_strongest( .or_insert(rule); } +/// Builds the *retained* candidates and reports how many there were. +/// +/// A dense window can hold thousands of vouchers sharing one manual number, and +/// every one of them used to be cloned into a `PresenceCandidate` before the +/// response cap discarded all but twenty-five. At the admitted bounds that is +/// millions of string clones to produce a bounded answer, so the cap is applied +/// **before** the clone and the true count is carried alongside it rather than +/// recovered from the vector's length. fn candidates_from( window: &BookWindow, positions: &[usize], rule: CandidateRule, -) -> Vec { - positions +) -> (Vec, usize) { + let retained = positions .iter() + .take(MAX_CANDIDATES_PER_PROPOSAL) .map(|position| PresenceCandidate { book_key: window.vouchers[*position].key().to_string(), rule, }) - .collect() + .collect(); + (retained, positions.len()) } -fn undecided(reason: UndecidedReason, candidates: Vec) -> Undecided { - let candidate_count = candidates.len(); - let truncated = candidate_count > MAX_CANDIDATES_PER_PROPOSAL; - let mut candidates = candidates; - candidates.truncate(MAX_CANDIDATES_PER_PROPOSAL); +fn undecided( + reason: UndecidedReason, + (candidates, found): (Vec, usize), +) -> Undecided { Undecided { reason, + candidates_truncated: candidates.len() < found, candidates, - candidate_count, - candidates_truncated: truncated, + candidate_count: found, } } diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 24cea4a9..ef099a6f 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1888,3 +1888,40 @@ fn a_window_declaring_remote_ids_unread_refuses_to_carry_one() { ) .is_ok()); } + +/// A dense window can hold thousands of vouchers sharing one manual number. +/// The response keeps twenty-five of them, so twenty-five is what may be +/// cloned — the count is carried alongside rather than recovered from the +/// vector's length, which is what let the old code allocate the whole set and +/// then throw it away. +#[test] +fn a_large_number_collision_reports_its_true_size_without_listing_it() { + let rows: Vec = (1..=200) + .map(|index| { + BookRow::new( + Box::leak(format!("book-{index:03}").into_boxed_str()), + "20260812", + "AA0118", + ) + }) + .collect(); + let window = window(&rows); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert_eq!(reason(entry), UndecidedReason::BookNumberCollision); + let undecided = entry.undecided().expect("undecided"); + assert_eq!(undecided.candidate_count, 200, "the true size is reported"); + assert_eq!(undecided.candidates.len(), MAX_CANDIDATES_PER_PROPOSAL); + assert!(undecided.candidates_truncated); + // Every one of them was still reached, so none is reported as a voucher no + // proposal came near. + assert_eq!(report.observations().unmatched_book_vouchers, 0); + // And the book-side diagnostic sees the collision it is there to find. + assert_eq!(report.observations().duplicate_number_group_count, 1); +} From f8925816716eedc0ce237e8900cb4d260151e1ea Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 09:26:02 +0530 Subject: [PATCH 34/91] Repair what five restructurings did to this file's own explanations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A self-review pass over decide(), which has been reorganised five times under review and had accumulated the damage that causes. A comment describing how manual numbering decides within an observed voucher type had been left stranded above the REMOTEID block when the number lookup moved: it described code that was no longer there, which is worse than no comment. The lookup's new position now explains itself — it sits above rule one so that both identities are resolved before either settles. A redundant condition: matches.len() == 1 && unique_here, where unique_here was already proven true by an early return twelve lines above. Replaced with the fact it was standing in for. And two comment blocks in differences() had stacked up across successive edits, each half-explaining the same narrowing. Merged into one that says there are two narrowings and gives each its own reason. No behaviour change; 181 core tests unchanged and green, clippy and fmt clean, gate passing. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +-- .../bridge-tally-core/src/book_presence.rs | 35 ++++++++++++------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 772729e4..0f866779 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": "44ef021bab8f455658602147825da431fa72b1912064e0dfee0b93581742a717", + "compatibility_surface_sha256": "092f7e8eab85190a70904d3fb2159021df87dbd24a5229c750bd64b9d27ccada", "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 488c0142..31db7878 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "66e9d2f52944856a4e3c962c264ac21911de4bdaff0536cf3fa3ac70b8a8b303" + "sha256": "70ae50561ee469d749337fbbc24b1fbd729c23c5122712564690e6869ac243aa" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -854,5 +854,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "44ef021bab8f455658602147825da431fa72b1912064e0dfee0b93581742a717" + "manifest_sha256": "092f7e8eab85190a70904d3fb2159021df87dbd24a5229c750bd64b9d27ccada" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 7a5b7809..a15dc2b0 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -1121,8 +1121,11 @@ fn decide( let remote_id_unverifiable = proposal.remote_id.is_some() && window.remote_id_evidence() == RemoteIdEvidence::NotRead; - // Rule two: a voucher number is identity only where the numbering method - // preserves it (§9.8), and only when it is unique on both sides. + // Both identity lookups are resolved *before* either settles, so that a + // `REMOTEID` selecting one voucher while the number selects another can be + // reported as a disagreement instead of decided by whichever ran first. + // That is why the number lookup sits above rule one rather than under + // rule two, where it is used. let number_matches: Vec = proposal .number_key .as_deref() @@ -1141,8 +1144,6 @@ fn decide( }) .unwrap_or_default(); - // Manual numbering only decides within an observed voucher type: numbers - // are a per-type series, so a cross-type number match is a resemblance. // Rule one: identity first. A REMOTEID is a key Bridge itself wrote. if let Some(remote_id) = proposal.remote_id.as_deref() { let unique_here = proposal_remote_counts.get(remote_id).copied() == Some(1); @@ -1162,7 +1163,9 @@ fn decide( ); } if !matches.is_empty() { - if matches.len() == 1 && unique_here { + // Uniqueness on the proposal side was settled above, so one match + // here is one match on both sides. + if matches.len() == 1 { // Both identities are resolved before either settles. A // REMOTEID selecting one voucher while the number selects // another is two identity signals disagreeing, and ranking one @@ -1212,9 +1215,12 @@ fn decide( } } - // A collision between two proposals is a fact about the *source*. It does - // not become less true because the book has never seen this voucher type, - // so it is settled before the observed-type guard rather than inside it. + // Rule two: a voucher number is identity only where the numbering method + // preserves it (§9.8), and only when it is unique on both sides. + // + // The proposal side comes first, because a collision between two proposals + // is a fact about the *source*: it does not become less true because the + // book has never seen this voucher type. if method == NumberingMethod::Manual { if let Some(number_key) = proposal.number_key.as_deref() { let proposed_twice = proposal_number_counts @@ -1438,12 +1444,15 @@ fn differences( observed: Some(voucher.magnitude.as_str().to_string()), }); } - // Only a bound party can disagree. An ambiguous one has no single name to - // disagree with, and asserting a difference from a candidate would be the - // same guess by another route. - // The diagnostic compares against the *observed party field*, not against + // Two narrowings, and each has a reason the other does not. + // + // Only a *bound* party can disagree: an ambiguous one has no single name to + // disagree with, and asserting a difference against a candidate would be + // the same guess by another route. + // + // And the comparison is against the *observed party field*, not against // every ledger the voucher touches. Widening to all entry ledgers is right - // for finding a candidate and wrong for reporting a disagreement: a + // for finding a candidate and wrong for reporting a disagreement — a // voucher whose party is one name while an entry names another would // otherwise report no difference while serializing the other name as // `observed`. A voucher with no party field has nothing to disagree with. From 171f9a27db62421fffb1de87a2de0e3f52abf1a0 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 09:28:34 +0530 Subject: [PATCH 35/91] Withhold absence when the source offered nothing decisive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more findings, and the first completes a pattern the previous rounds had been approaching one case at a time. A proposal under a Manual declaration that supplies no voucher number could be reported absent. The number is the one key that can decide under that declaration, so the absence rested on date, party and amount — the combination this contract exists to refuse. Withheld as ManualNumberNotSupplied. That is now the third condition of one shape, and the ADR states it as one rule rather than three exceptions: the source offered nothing the decisive rules could use. No party, no manual number under a Manual type, or a REMOTEID the window never read. None of them blocks present — identity still settles where it can. Only the absence claim is withheld, and supplying the missing field is what restores it. Second: the book observations sit outside the paged rows, so a consumer cannot trim them, and each duplicate group echoed a voucher type and number bounded only by the crate's pathological-input limit. Twenty-five groups could therefore exceed a byte budget that trimming rows could no longer rescue. Those two fields are recognition labels, not keys — a group's identity is its book_keys, bounded by count — so they are bounded to 128 characters and the ADR says why. 184 core and 868 lib green, clippy and fmt clean, gate passing. Co-Authored-By: Claude Opus 5 --- docs/adr/0017-voucher-presence-authority.md | 17 +++++ .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/book_presence.rs | 40 +++++++++- .../src/book_presence_tests.rs | 74 +++++++++++++++++++ 5 files changed, 132 insertions(+), 5 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 64d7a1ac..2f0805f8 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -89,6 +89,17 @@ consumed as-is: resembling one cannot be carrying a posted voucher in this book, so party-independent rules are all that remain and `Absent` stays available. +Three further conditions withhold `Absent` before any of that, and they share +one shape: **the source offered nothing the decisive rules could use.** A +proposal that names no party (`PartyNotSupplied`) leaves an absence resting on +date and amount, the pair this contract says collides. A proposal under a +`Manual` declaration that supplies no voucher number +(`ManualNumberNotSupplied`) has withheld the one key that could decide. And a +proposal carrying a `REMOTEID` the window never read +(`RemoteIdEvidenceUnavailable`) had its strongest key skipped. None of these +blocks `Present` — identity still settles where it can; only the *absence* +claim is withheld, and supplying the missing field is what makes it available. + Two binding outcomes withhold `Absent` outright: `NoDiscriminatingCandidate` (a name family that is deliberately not listed) and a truncated candidate list. In both, names that might have matched were never compared, and reporting @@ -343,6 +354,12 @@ reported alongside the verdicts rather than discarded: - `unclaimed_book_vouchers` — how many vouchers in the window no proposal matched. Counted only; listing them is a different report. +These sit **outside** the paged rows, so a consumer's response machinery cannot +trim them: an unbounded echo here could push a complete report past a byte +budget that trimming rows could no longer rescue. The two echoed strings are +therefore bounded, and treated as what they are — **recognition labels, not +keys.** A group's identity is its `book_keys`, which are bounded by count. + A voucher's magnitude is the sum of its positive entry amounts, computed in exact decimal. It is defined whether or not the voucher balances, so an unbalanced book voucher still participates in every amount rule — it is diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 0f866779..b84c995c 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": "092f7e8eab85190a70904d3fb2159021df87dbd24a5229c750bd64b9d27ccada", + "compatibility_surface_sha256": "46ffa15771cd23f2ba04d6be186c9cfaade13f17a1c0e38fcbdfe3bbfcd972c2", "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 31db7878..484833bb 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "70ae50561ee469d749337fbbc24b1fbd729c23c5122712564690e6869ac243aa" + "sha256": "c22431375c32864c55b704f2c01caae523a5ac35c5d62c3662f13f55332a4a77" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -854,5 +854,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "092f7e8eab85190a70904d3fb2159021df87dbd24a5229c750bd64b9d27ccada" + "manifest_sha256": "46ffa15771cd23f2ba04d6be186c9cfaade13f17a1c0e38fcbdfe3bbfcd972c2" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index a15dc2b0..12681715 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -43,6 +43,14 @@ pub const MAX_DUPLICATE_NUMBER_GROUPS: usize = 25; pub const MAX_KEYS_PER_DUPLICATE_GROUP: usize = 10; /// Most unbalanced book vouchers listed in the book observations. pub const MAX_UNBALANCED_LISTED: usize = 25; +/// Longest echoed label in the book observations. +/// +/// The observations sit outside the paged rows, so a consumer's response +/// machinery cannot trim them — an unbounded echo there can push a complete +/// report past a byte budget that trimming rows could no longer rescue. These +/// two fields are **recognition labels**, not keys: a group's identity is its +/// `book_keys`, which are bounded by count. +pub const MAX_OBSERVATION_LABEL_CHARS: usize = 128; /// Longest accepted text field, in characters. This bounds pathological input; /// it is not a claim about what Tally accepts. pub const MAX_TEXT_CHARS: usize = 16_384; @@ -545,6 +553,11 @@ pub enum UndecidedReason { /// The party comparison could not be completed, so no rule that needs a /// party actually ran and `Absent` is not available. PartyNotDecidable, + /// The voucher type is declared `Manual`, so the number is the one key + /// that could decide — and the source supplied none. Nothing was skipped, + /// and nothing decisive was offered either, so the absence would rest on + /// resemblance alone. + ManualNumberNotSupplied, /// The source named no party at all. Nothing was skipped — but nothing was /// compared either, and a book voucher for the same party and amount on /// another date would never have surfaced. `Present` is still reachable by @@ -578,6 +591,7 @@ impl UndecidedReason { Self::ResemblesBookVoucher => "presence_resembles_book_voucher", Self::PartyNotDecidable => "presence_party_not_decidable", Self::PartyNotSupplied => "presence_party_not_supplied", + Self::ManualNumberNotSupplied => "presence_manual_number_not_supplied", Self::BookVoucherClaimedTwice => "presence_book_voucher_claimed_twice", Self::IdentityConflict => "presence_identity_conflict", Self::RemoteIdEvidenceUnavailable => "presence_remote_id_evidence_unavailable", @@ -1344,6 +1358,19 @@ fn decide( BTreeSet::new(), ); } + // Under a Manual declaration the number is the deciding key. A + // proposal that supplies none has offered nothing decisive, so an + // absence would rest on date, party and amount — which this contract + // does not let decide. + if method == NumberingMethod::Manual && proposal.number_key.is_none() { + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::ManualNumberNotSupplied, + (Vec::new(), 0), + )), + BTreeSet::new(), + ); + } if party.incomplete { let reason = match party.outcome { PartyOutcome::NotSupplied => UndecidedReason::PartyNotSupplied, @@ -1488,8 +1515,12 @@ fn observe( } let first = &window.vouchers[positions[0]]; duplicate_numbers.push(DuplicateNumberGroup { - voucher_type: first.voucher_type.clone(), - voucher_number: first.voucher_number.clone().unwrap_or_default(), + voucher_type: label(&first.voucher_type), + voucher_number: first + .voucher_number + .as_deref() + .map(label) + .unwrap_or_default(), book_keys: positions .iter() .take(MAX_KEYS_PER_DUPLICATE_GROUP) @@ -1530,6 +1561,11 @@ fn observe( } } +/// Bounds an echoed observation label. See `MAX_OBSERVATION_LABEL_CHARS`. +fn label(value: &str) -> String { + value.chars().take(MAX_OBSERVATION_LABEL_CHARS).collect() +} + fn keep_strongest( found: &mut BTreeMap, position: usize, diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index ef099a6f..85eb73a8 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1925,3 +1925,77 @@ fn a_large_number_collision_reports_its_true_size_without_listing_it() { // And the book-side diagnostic sees the collision it is there to find. assert_eq!(report.observations().duplicate_number_group_count, 1); } + +/// Under a `Manual` declaration the number is the one key that can decide, so +/// a proposal supplying none has offered nothing decisive — an absence would +/// rest on date, party and amount, which this contract does not let decide. +#[test] +fn a_manual_type_without_a_number_cannot_be_reported_absent() { + let window = window(&[BookRow::new("book-1", "20260819", "AA0130").party("Bravo Industries")]); + let mut proposal = ProposalRow::new(0, "20260812", "AA0999"); + proposal.number = None; + proposal = proposal.party("Charlie Minerals").rows(vec![ + ["Charlie Minerals", "-77.00"], + ["Sales Account", "77.00"], + ]); + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &[proposal.build()], + ); + let entry = only(&report); + assert!(!entry.is_absent()); + assert_eq!(reason(entry), UndecidedReason::ManualNumberNotSupplied); +} + +#[test] +fn an_automatic_type_without_a_number_is_still_answerable() { + // Under automatic numbering the number was never decisive, so omitting it + // skips nothing and the absence stands on the rules that could run. + let window = window(&[BookRow::new("book-1", "20260819", "AA0130").party("Bravo Industries")]); + let mut proposal = ProposalRow::new(0, "20260812", "AA0999"); + proposal.number = None; + proposal = proposal.party("Charlie Minerals").rows(vec![ + ["Charlie Minerals", "-77.00"], + ["Sales Account", "77.00"], + ]); + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Automatic), + &[proposal.build()], + ); + assert!(only(&report).is_absent()); +} + +/// The observations sit outside the paged rows, so a consumer cannot trim +/// them. An unbounded echo there can push a complete report past a byte budget +/// that trimming rows could no longer rescue. +#[test] +fn book_observation_labels_are_bounded() { + let long: &'static str = Box::leak( + "N".repeat(MAX_OBSERVATION_LABEL_CHARS + 50) + .into_boxed_str(), + ); + let window = window(&[ + BookRow::new("book-1", "20260812", long), + BookRow::new("book-2", "20260813", long).party("Bravo Industries"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let group = &report.observations().duplicate_numbers[0]; + assert_eq!( + group.voucher_number.chars().count(), + MAX_OBSERVATION_LABEL_CHARS + ); + // The group's identity is its keys, which are bounded by count, not by the + // label that helps a human recognise it. + assert_eq!(group.book_keys, vec!["book-1", "book-2"]); + assert_eq!(group.book_voucher_count, 2); +} From 19981cffef1d3ed52a49cc613ae48a4b5a10b200 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 09:29:02 +0530 Subject: [PATCH 36/91] Say which absences the withholding rule does not cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'The source offered nothing the decisive rules could use' reads as though it also covers a voucher type numbered Automatic, where no decisive key exists at all. It does not, and the difference matters: a proposal under automatic numbering withheld nothing, so its absence stands on the resemblance rules having found nothing — which is exactly what absent claims. The rule is about evidence that was available and is missing, never about evidence that does not exist. Docs only. Co-Authored-By: Claude Opus 5 --- docs/adr/0017-voucher-presence-authority.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 2f0805f8..abef104f 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -90,7 +90,12 @@ consumed as-is: party-independent rules are all that remain and `Absent` stays available. Three further conditions withhold `Absent` before any of that, and they share -one shape: **the source offered nothing the decisive rules could use.** A +one shape: **a field the rules would otherwise have used was not supplied.** +Note what that does *not* cover — a voucher type numbered `Automatic` has no +decisive key at all, so a proposal under it withheld nothing and its `Absent` +stands on the resemblance rules having found nothing, which is exactly what +`Absent` claims. The withholding is for evidence that was available and is +missing, never for evidence that does not exist. A proposal that names no party (`PartyNotSupplied`) leaves an absence resting on date and amount, the pair this contract says collides. A proposal under a `Manual` declaration that supplies no voucher number From 63c065b703a0d19749ec95781088e7923c68b2de Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 09:31:06 +0530 Subject: [PATCH 37/91] Price the two writer-key failures separately, because only one disqualifies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The asymmetry dependency in section 7 listed the re-download and overlapping-window cases as two things to test, as though they were the same kind of finding. The bank-import lane pointed out they land on opposite sides of this contract's own ledger. Re-download collides two different events onto one key, so one silently overwrites the other — the same side as a false present, nothing to find later. That is a reason to refuse the key outright. Overlapping windows split one event across two keys, so it posts twice — a visible, correctable duplicate, the side this design already tolerates. That is a reason to fix the key, not reject it. Both must be tested; only the first disqualifies. Stating that makes the dependency actionable instead of a caution. Docs only. Gate re-run and passing. Co-Authored-By: Claude Opus 5 --- docs/adr/0017-voucher-presence-authority.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index abef104f..60c4da79 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -340,8 +340,20 @@ two distinct events — a row ordinal within a re-downloaded window, say — tur different voucher**, which lands on the same side of the ledger as a false `Present`: no duplicate to see, no exception raised, nothing to find later. A consumer acting on `Absent` inherits that risk from the writer, not from this -report. Any key proposed for that writer should be tested against both the -re-download case and the overlapping-window case before it is trusted. +report. + +Any key proposed for that writer has to be exercised against two cases, and +**they are not symmetric in cost** — which decides what to do about each: + +- **Re-download.** The same window fetched twice collides two different events + onto one key, so one silently overwrites the other. That lands on the same + side as a false `Present`, and it is a reason to **refuse the key outright**. +- **Overlapping window.** Two fetches that share rows split one event across + two keys, so it posts twice. That is a visible, correctable duplicate — the + side this design already tolerates, and a reason to fix the key rather than + reject it. + +Both must be tested. Only the first disqualifies. The cost of this posture is operator review time. That is the intended cost: the middle is where a human is genuinely faster than any rule, and the From a739946a7c2ce30bbae6307d87de7bef8d7c6e89 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 10:08:28 +0530 Subject: [PATCH 38/91] Assert this contract's pins, because the gate structurally cannot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on this branch rather than reasoned about. Drop one of the two pins this PR adds, reseal correctly, and run the gate: rehash_surface_changed:0 compatibility_gate_passed:unknown_claims=11:evidenced_claims=0 pinned files: 212 against MAX_SURFACE_FILES 213 It passes. The bound is MAX_SURFACE_FILES - files.len() <= RESERVED_SURFACE_FILES, which asserts there is no unreviewed headroom — a different proposition from "this cap matches this surface". A guard on the slack cannot see a pin quietly lost. The way to lose one is not carelessness, it is the correct procedure: resolving a generated-artifact conflict by taking the base side drops the entries a branch adds, because rehash-surface updates hashes and never adds paths. A sibling lane hit the same blind spot from the other direction, landing 210 pins under a cap of 211 with a file unpinned and the gate green. So the claim is asserted in a test instead. It lives in this contract's own test file, which is not itself pinned, so it adds no conflict surface to the files that collide. Verified to fail — dropping a pin produces "src-tauri/src/agent_presence.rs is no longer pinned" rather than a silent pass. A procedural check that must be remembered after every conflict is the kind that gets skipped exactly once. 185 core tests green, clippy and fmt clean, surface restored at 213 pins and the gate passing. Co-Authored-By: Claude Opus 5 --- .../src/book_presence_tests.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 85eb73a8..28c606d1 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1999,3 +1999,40 @@ fn book_observation_labels_are_bounded() { assert_eq!(group.book_keys, vec!["book-1", "book-2"]); assert_eq!(group.book_voucher_count, 2); } + +/// This contract's two production files are pinned in the compatibility +/// surface, and there is a way for that to stop being true **silently**. +/// +/// Resolving a surface conflict by taking the base side — which is the only +/// correct way to resolve a generated artifact — drops the entries a branch +/// *adds*, because `rehash-surface` updates hashes and never adds paths. The +/// compatibility gate does not catch it: its bound is +/// `MAX_SURFACE_FILES - files.len() <= RESERVED_SURFACE_FILES`, which asserts +/// there is no unreviewed *headroom* rather than that the cap matches the +/// surface. A guard on the slack cannot catch a claim made too early, or a pin +/// quietly lost. +/// +/// So the claim is asserted here instead, in a file that is not itself pinned. +/// If a rebase ever drops these two, this fails loudly rather than the seal +/// passing over a surface that no longer covers the engine it was raised for. +#[test] +fn this_contracts_files_are_still_pinned_in_the_compatibility_surface() { + const SURFACE: &str = + include_str!("../../../../docs/tally/compatibility/compatibility-surface.json"); + let surface: serde_json::Value = serde_json::from_str(SURFACE).expect("surface json"); + let pinned = surface["files"] + .as_array() + .expect("files") + .iter() + .filter_map(|entry| entry["path"].as_str()) + .collect::>(); + for path in [ + "src-tauri/crates/bridge-tally-core/src/book_presence.rs", + "src-tauri/src/agent_presence.rs", + ] { + assert!( + pinned.contains(path), + "{path} is no longer pinned: a conflict resolution dropped it and the gate cannot see that" + ); + } +} From ef5d102bc7c45048c22f3fdd693a3e0266579984 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Fri, 11 Sep 2026 10:12:30 +0530 Subject: [PATCH 39/91] Stop a collision from hiding what the proposal resembled, and bound the key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings. unmatched_book_vouchers promises to count rows no proposal matched or even resembled. A collision returns before rule three runs, carrying only its colliding positions — so a book row the proposal plainly resembles on date, party and amount was counted as one nothing came near. The diagnostic contradicted its own definition on exactly the runs where it matters most. The resemblance scan is now a function, and every early return that carries an incomplete touched set unions it in without changing its verdict. Extracting it was overdue anyway: decide() had grown a fifty-line inline scan between two identity rules. Second: a book key was bounded only by the crate's pathological-input limit, at 16,384 characters. It is echoed in every candidate and a consumer's framing can drop whole rows but cannot shrink one, so twenty-five colliding rows could put 400KB in a single item and defeat any page budget. Bounded to 128 — far above a Tally GUID's 36-character prefix and suffix — and it refuses rather than truncates, because a key is an identity and half of one joins to nothing. 187 core and 868 lib green, clippy and fmt clean, gate passing at 213 pins with none removed. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/book_presence.rs | 113 ++++++++++++------ .../src/book_presence_tests.rs | 79 ++++++++++++ 4 files changed, 161 insertions(+), 37 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index b84c995c..5b1edd24 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": "46ffa15771cd23f2ba04d6be186c9cfaade13f17a1c0e38fcbdfe3bbfcd972c2", + "compatibility_surface_sha256": "df9c74589744efcde8177bba8cc70e95a37b5ed85ff900e9fec01e139429f82e", "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 484833bb..3e196fc6 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "c22431375c32864c55b704f2c01caae523a5ac35c5d62c3662f13f55332a4a77" + "sha256": "7124761c3b65b7898c3841e93156667eee1c24a2cc57cff2ad886fc7efae5177" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -854,5 +854,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "46ffa15771cd23f2ba04d6be186c9cfaade13f17a1c0e38fcbdfe3bbfcd972c2" + "manifest_sha256": "df9c74589744efcde8177bba8cc70e95a37b5ed85ff900e9fec01e139429f82e" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 12681715..1615d694 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -43,6 +43,17 @@ pub const MAX_DUPLICATE_NUMBER_GROUPS: usize = 25; pub const MAX_KEYS_PER_DUPLICATE_GROUP: usize = 10; /// Most unbalanced book vouchers listed in the book observations. pub const MAX_UNBALANCED_LISTED: usize = 25; +/// Longest accepted book-voucher key. +/// +/// The key is echoed in every candidate, and a response can carry twenty-five +/// of them per proposal, so an unbounded key defeats any page budget: a +/// consumer's framing can drop whole rows but cannot shrink one. A Tally +/// voucher GUID is a 36-character company prefix and a short suffix, so this +/// is far above anything real and refuses only pathological input — and it +/// *refuses* rather than truncates, because a key is an identity and half of +/// one joins to nothing. +pub const MAX_BOOK_KEY_CHARS: usize = 128; + /// Longest echoed label in the book observations. /// /// The observations sit outside the paged rows, so a consumer's response @@ -79,6 +90,8 @@ pub enum PresenceError { /// would let a verdict settle on evidence the window says was not gathered. #[error("book window declared REMOTEID unread while carrying one")] WindowRemoteIdContradiction, + #[error("book voucher key exceeded its bound")] + VoucherKeyTooLong, /// A proposal dated outside the window would be judged against evidence /// that could not contain it. #[error("book window does not cover every proposed date")] @@ -122,6 +135,7 @@ impl PresenceError { Self::WindowVoucherOutsideRange => "presence_window_voucher_outside_range", Self::WindowDuplicateVoucherKey => "presence_window_duplicate_voucher_key", Self::WindowRemoteIdContradiction => "presence_window_remote_id_contradiction", + Self::VoucherKeyTooLong => "presence_voucher_key_too_long", Self::WindowDoesNotCover => "presence_window_does_not_cover", Self::ProposalsEmpty => "presence_proposals_empty", Self::TooManyProposals => "presence_proposals_too_many", @@ -246,6 +260,9 @@ pub struct BookVoucher { impl BookVoucher { pub fn observed(input: ObservedVoucher<'_>) -> Result { let key = validated_text(input.key)?; + if key.chars().count() > MAX_BOOK_KEY_CHARS { + return Err(PresenceError::VoucherKeyTooLong); + } let date = TallyDate::parse(input.date.to_string()).map_err(|_| PresenceError::DateInvalid)?; let voucher_type = validated_text(input.voucher_type)?; @@ -1158,6 +1175,14 @@ fn decide( }) .unwrap_or_default(); + // A verdict decided before rule three still *reached* whatever it + // resembles, and the observations count what no proposal came near. + let with_resemblances = |touched: BTreeSet| { + let mut touched = touched; + touched.extend(resemblances(proposal, party, window, index, &number_matches).into_keys()); + touched + }; + // Rule one: identity first. A REMOTEID is a key Bridge itself wrote. if let Some(remote_id) = proposal.remote_id.as_deref() { let unique_here = proposal_remote_counts.get(remote_id).copied() == Some(1); @@ -1173,7 +1198,7 @@ fn decide( UndecidedReason::RemoteIdCollision, candidates_from(window, matches, CandidateRule::SharedRemoteId), )), - matches.iter().copied().collect(), + with_resemblances(matches.iter().copied().collect()), ); } if !matches.is_empty() { @@ -1206,7 +1231,7 @@ fn decide( UndecidedReason::IdentityConflict, (candidates, found), )), - touched, + with_resemblances(touched), ); } return shell( @@ -1224,7 +1249,7 @@ fn decide( UndecidedReason::RemoteIdCollision, candidates_from(window, matches, CandidateRule::SharedRemoteId), )), - matches.iter().copied().collect(), + with_resemblances(matches.iter().copied().collect()), ); } } @@ -1252,7 +1277,7 @@ fn decide( CandidateRule::SharedVoucherNumber, ), )), - number_matches.iter().copied().collect(), + with_resemblances(number_matches.iter().copied().collect()), ); } } @@ -1315,36 +1340,7 @@ fn decide( // Rule three: everything else is resemblance, and resemblance decides // nothing. It only widens what a person is asked to look at. - let mut found: BTreeMap = BTreeMap::new(); - for position in &number_matches { - keep_strongest(&mut found, *position, CandidateRule::SharedVoucherNumber); - } - let mut pool: BTreeSet = BTreeSet::new(); - if let Some(positions) = index.by_date.get(proposal.date()) { - pool.extend(positions.iter().copied()); - } - for key in &party.compare_keys { - if let Some(positions) = index.by_ledger.get(key.as_str()) { - pool.extend(positions.iter().copied()); - } - } - for position in pool { - let voucher = &window.vouchers[position]; - let same_date = voucher.date() == proposal.date(); - let same_amount = voucher.magnitude.numeric_eq(&proposal.magnitude); - let same_party = party - .compare_keys - .iter() - .any(|key| voucher.ledger_keys.contains(key)); - let rule = match (same_date, same_party, same_amount) { - (true, true, true) => CandidateRule::SameDatePartyAmount, - (_, true, true) => CandidateRule::SamePartyAmount, - (true, false, true) => CandidateRule::SameDateAmount, - (true, true, false) => CandidateRule::SameDateParty, - _ => continue, - }; - keep_strongest(&mut found, position, rule); - } + let found = resemblances(proposal, party, window, index, &number_matches); if found.is_empty() { // Nothing resembled it — but an absence is only evidence when every @@ -1419,6 +1415,55 @@ fn decide( ) } +/// Every book voucher this proposal resembles, strongest rule per voucher. +/// +/// Extracted because the *touched* set it produces is needed even on paths that +/// return before resemblance can decide anything. A collision returns early +/// with only its colliding positions, and `unmatched_book_vouchers` promises to +/// count rows no proposal "matched or even resembled" — so a row this proposal +/// plainly resembles must not be counted there merely because a collision +/// outranked the resemblance. The scan is indexed, and the paths that need it +/// early are collisions, which are rare. +fn resemblances( + proposal: &ProposedVoucher, + party: &PartyResolution, + window: &BookWindow, + index: &WindowIndex<'_>, + number_matches: &[usize], +) -> BTreeMap { + let mut found: BTreeMap = BTreeMap::new(); + for position in number_matches { + keep_strongest(&mut found, *position, CandidateRule::SharedVoucherNumber); + } + let mut pool: BTreeSet = BTreeSet::new(); + if let Some(positions) = index.by_date.get(proposal.date()) { + pool.extend(positions.iter().copied()); + } + for key in &party.compare_keys { + if let Some(positions) = index.by_ledger.get(key.as_str()) { + pool.extend(positions.iter().copied()); + } + } + for position in pool { + let voucher = &window.vouchers[position]; + let same_date = voucher.date() == proposal.date(); + let same_amount = voucher.magnitude.numeric_eq(&proposal.magnitude); + let same_party = party + .compare_keys + .iter() + .any(|key| voucher.ledger_keys.contains(key)); + let rule = match (same_date, same_party, same_amount) { + (true, true, true) => CandidateRule::SameDatePartyAmount, + (_, true, true) => CandidateRule::SamePartyAmount, + (true, false, true) => CandidateRule::SameDateAmount, + (true, true, false) => CandidateRule::SameDateParty, + _ => continue, + }; + keep_strongest(&mut found, position, rule); + } + found +} + /// Turns an identity match into a status. A cancelled or optional voucher /// occupies the number without being posted, so it is never `Present`. fn settled( diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 28c606d1..0fe1218b 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -2036,3 +2036,82 @@ fn this_contracts_files_are_still_pinned_in_the_compatibility_surface() { ); } } + +/// `unmatched_book_vouchers` promises to count rows no proposal matched **or +/// even resembled**. A collision returns before rule three, so without help it +/// would report a row this proposal plainly resembles as one nothing came +/// near — the diagnostic contradicting itself. +#[test] +fn a_collision_still_counts_what_the_proposal_resembled() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let money = vec![ + ["Alpha Traders", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ["Output SGST 9%", "900.00"], + ]; + // Two proposals share a REMOTEID the book does not carry, so the collision + // decides — but both plainly resemble book-1 on date, party and amount. + let proposals = [ + ProposalRow::new(0, "20260812", "AA0901") + .remote_id("tally-9") + .rows(money.clone()) + .build(), + ProposalRow::new(1, "20260812", "AA0902") + .remote_id("tally-9") + .rows(money) + .build(), + ]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + for entry in report.vouchers() { + assert_eq!(reason(entry), UndecidedReason::RemoteIdCollision); + } + assert_eq!( + report.observations().unmatched_book_vouchers, + 0, + "book-1 was resembled by both proposals, whatever decided them" + ); +} + +/// A key is an identity, so a pathological one is refused rather than cut — +/// half a key joins to nothing. It is also echoed in every candidate, and a +/// consumer's framing can drop whole rows but cannot shrink one. +#[test] +fn a_pathological_book_key_is_refused_rather_than_truncated() { + let rows = [["Alpha Traders", "-1.00"], ["Sales Account", "1.00"]]; + let entries = entries(&rows); + let long: String = "g".repeat(MAX_BOOK_KEY_CHARS + 1); + assert_eq!( + BookVoucher::observed(ObservedVoucher { + key: &long, + date: "20260812", + voucher_type: "Sales", + voucher_number: Some("AA0118"), + remote_id: None, + party: Some("Alpha Traders"), + entries: &entries, + cancelled: false, + optional: false, + }) + .expect_err("pathological key"), + PresenceError::VoucherKeyTooLong + ); + // A real Tally GUID — company prefix plus master id — is far inside it. + assert!(BookVoucher::observed(ObservedVoucher { + key: "61c6de69-1748-461c-ad3f-162cb949df9f-00000001", + date: "20260812", + voucher_type: "Sales", + voucher_number: Some("AA0118"), + remote_id: None, + party: Some("Alpha Traders"), + entries: &entries, + cancelled: false, + optional: false, + }) + .is_ok()); +} From b1e1f2b69c58ad4280607905aac760191efa069e Mon Sep 17 00:00:00 2001 From: t Date: Fri, 11 Sep 2026 10:48:22 +0530 Subject: [PATCH 40/91] Rank candidates once, and stop identity settles hiding what they reached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from the round my last push invited. Candidate order is part of the contract, and the collision paths were capping an arbitrary source prefix rather than the ranked one — so the same book could expose a different twenty-five depending on the order Tally returned its rows in. Worse, the identity-conflict path appended two lists and truncated, which drops the number side wholesale when the REMOTEID side alone fills the cap, hiding half of the disagreement that status exists to report. Both are gone because there is now one ranked constructor instead of three call sites. It sorts (position, rule) pairs — no allocation — then clones only the prefix it keeps, so ordering and the cap-before-clone property hold everywhere by construction rather than per site. An identity settle recorded only the row it identified, so a row it also resembled was counted as one no proposal came near. Same omission I fixed for collisions last round and left on the settle paths. The echoed party names in a difference were bounded only by the pathological-input limit, and a response can drop whole rows but cannot shrink one. Bounded like the observation labels; the comparison that produced the difference already used the full values. And the admission contract this tool enforces lives in an unpinned file. The bounds are safe — the schema references constants in pinned files — but the structure is not: an edit could drop additionalProperties, widen the numbering enum, or remove a required field while the seal stayed valid. Asserted structurally in a pinned test file instead. Pinning agent_catalog.rs is stronger and is not this lane's call: it would make every tool change reseal, and move the cap arithmetic the merge order already depends on. 190 core and 869 lib green, clippy and fmt clean, gate passing at 213 pins with none removed. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/book_presence.rs | 109 ++++++++-------- .../src/book_presence_tests.rs | 116 ++++++++++++++++++ src-tauri/src/agent_presence_tests.rs | 87 +++++++++++++ 5 files changed, 267 insertions(+), 51 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 5b1edd24..9833a839 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": "df9c74589744efcde8177bba8cc70e95a37b5ed85ff900e9fec01e139429f82e", + "compatibility_surface_sha256": "b23f6339b68663a7756afb225cb128e9b648705ee677bb2c767739667693f8cb", "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 3e196fc6..83edc843 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "7124761c3b65b7898c3841e93156667eee1c24a2cc57cff2ad886fc7efae5177" + "sha256": "5732eb25a61bc2137a77a97753a04367ea5065023b1f6aac618e6106d204f6a3" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -854,5 +854,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "df9c74589744efcde8177bba8cc70e95a37b5ed85ff900e9fec01e139429f82e" + "manifest_sha256": "b23f6339b68663a7756afb225cb128e9b648705ee677bb2c767739667693f8cb" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 1615d694..d0a85a3b 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -1175,8 +1175,9 @@ fn decide( }) .unwrap_or_default(); - // A verdict decided before rule three still *reached* whatever it - // resembles, and the observations count what no proposal came near. + // A verdict decided before rule three — an identity match as much as a + // collision — still *reached* whatever it resembles, and the observations + // count only what no proposal came near. let with_resemblances = |touched: BTreeSet| { let mut touched = touched; touched.extend(resemblances(proposal, party, window, index, &number_matches).into_keys()); @@ -1216,20 +1217,23 @@ fn decide( if number_selects_another { let mut touched = BTreeSet::from([matches[0]]); touched.insert(number_matches[0]); - let (mut candidates, mut found) = - candidates_from(window, matches, CandidateRule::SharedRemoteId); - let (by_number, number_found) = candidates_from( - window, - &number_matches, - CandidateRule::SharedVoucherNumber, - ); - candidates.extend(by_number); - candidates.truncate(MAX_CANDIDATES_PER_PROPOSAL); - found += number_found; + // Both sides go through one ranked constructor. Appending + // and truncating could drop the number side wholesale when + // the REMOTEID side alone filled the cap — hiding half of + // the disagreement this status exists to report. + let mut entries = matches + .iter() + .map(|position| (*position, CandidateRule::SharedRemoteId)) + .chain( + number_matches + .iter() + .map(|position| (*position, CandidateRule::SharedVoucherNumber)), + ) + .collect::>(); return shell( PresenceStatus::PossiblyPresent(undecided( UndecidedReason::IdentityConflict, - (candidates, found), + candidates_ranked(window, &mut entries), )), with_resemblances(touched), ); @@ -1241,7 +1245,7 @@ fn decide( &window.vouchers[matches[0]], PresenceBasis::RemoteId, ), - BTreeSet::from([matches[0]]), + with_resemblances(BTreeSet::from([matches[0]])), ); } return shell( @@ -1333,7 +1337,7 @@ fn decide( } return shell( settled(proposal, party, matched, PresenceBasis::ManualVoucherNumber), - touched, + with_resemblances(touched), ); } } @@ -1393,24 +1397,9 @@ fn decide( // Ordered as (position, rule) pairs before anything is cloned: the order is // rule-then-key and only the retained prefix needs a key at all. let mut ordered = found.into_iter().collect::>(); - ordered.sort_by(|(left_position, left_rule), (right_position, right_rule)| { - left_rule.rank().cmp(&right_rule.rank()).then_with(|| { - window.vouchers[*left_position] - .key() - .cmp(window.vouchers[*right_position].key()) - }) - }); - let total = ordered.len(); - let candidates = ordered - .into_iter() - .take(MAX_CANDIDATES_PER_PROPOSAL) - .map(|(position, rule)| PresenceCandidate { - book_key: window.vouchers[position].key().to_string(), - rule, - }) - .collect::>(); + let ranked = candidates_ranked(window, &mut ordered); shell( - PresenceStatus::PossiblyPresent(undecided(reason, (candidates, total))), + PresenceStatus::PossiblyPresent(undecided(reason, ranked)), touched, ) } @@ -1534,8 +1523,11 @@ fn differences( if comparison_key(observed) != comparison_key(catalog_name) { differences.push(Difference { field: DifferenceField::Party, - proposed: Some(catalog_name.clone()), - observed: Some(observed.to_string()), + // Bounded for the same reason the observation labels are: a + // response can drop whole rows but cannot shrink one, and the + // comparison above already used the full values. + proposed: Some(label(catalog_name)), + observed: Some(label(observed)), }); } } @@ -1626,28 +1618,49 @@ fn keep_strongest( .or_insert(rule); } -/// Builds the *retained* candidates and reports how many there were. +/// Builds the *retained* candidates, ordered, and reports how many there were. /// -/// A dense window can hold thousands of vouchers sharing one manual number, and -/// every one of them used to be cloned into a `PresenceCandidate` before the -/// response cap discarded all but twenty-five. At the admitted bounds that is -/// millions of string clones to produce a bounded answer, so the cap is applied -/// **before** the clone and the true count is carried alongside it rather than -/// recovered from the vector's length. -fn candidates_from( +/// Two properties, and the second is why this is one function rather than +/// three call sites. **Ordering is part of the contract** — rule, then book key +/// — so a dense collision exposes the same subset however Tally happened to +/// order its rows, and a reviewer comparing two runs of an unchanged book does +/// not see a different twenty-five. And the cap is applied **before** the +/// clone: a window can hold thousands of vouchers on one number, and cloning +/// them all to discard all but twenty-five is millions of allocations for a +/// bounded answer. Sorting `(position, rule)` pairs allocates nothing. +fn candidates_ranked( window: &BookWindow, - positions: &[usize], - rule: CandidateRule, + entries: &mut [(usize, CandidateRule)], ) -> (Vec, usize) { - let retained = positions + entries.sort_by(|(left, left_rule), (right, right_rule)| { + left_rule.rank().cmp(&right_rule.rank()).then_with(|| { + window.vouchers[*left] + .key() + .cmp(window.vouchers[*right].key()) + }) + }); + let found = entries.len(); + let retained = entries .iter() .take(MAX_CANDIDATES_PER_PROPOSAL) - .map(|position| PresenceCandidate { + .map(|(position, rule)| PresenceCandidate { book_key: window.vouchers[*position].key().to_string(), - rule, + rule: *rule, }) .collect(); - (retained, positions.len()) + (retained, found) +} + +fn candidates_from( + window: &BookWindow, + positions: &[usize], + rule: CandidateRule, +) -> (Vec, usize) { + let mut entries = positions + .iter() + .map(|position| (*position, rule)) + .collect::>(); + candidates_ranked(window, &mut entries) } fn undecided( diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 0fe1218b..22b48b94 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -2115,3 +2115,119 @@ fn a_pathological_book_key_is_refused_rather_than_truncated() { }) .is_ok()); } + +/// Candidate order is part of the contract, so the same book must yield the +/// same twenty-five whatever order Tally happened to return its rows in. The +/// cap is applied after ranking, never to an arbitrary source prefix. +#[test] +fn a_capped_collision_list_does_not_depend_on_the_rows_arriving_order() { + let keys: Vec<&'static str> = (1..=40) + .map(|index| Box::leak(format!("book-{index:03}").into_boxed_str()) as &'static str) + .collect(); + let listed = |order: Vec<&'static str>| { + let rows: Vec = order + .into_iter() + .enumerate() + .map(|(offset, key)| { + BookRow::new( + key, + if offset % 2 == 0 { + "20260812" + } else { + "20260813" + }, + "AA0118", + ) + }) + .collect(); + let window = window(&rows); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let undecided = only(&report).undecided().expect("undecided").clone(); + assert_eq!(undecided.reason, UndecidedReason::BookNumberCollision); + assert_eq!(undecided.candidate_count, 40); + undecided + .candidates + .iter() + .map(|candidate| candidate.book_key.clone()) + .collect::>() + }; + let ascending = listed(keys.clone()); + let reversed = listed(keys.into_iter().rev().collect()); + assert_eq!(ascending.len(), MAX_CANDIDATES_PER_PROPOSAL); + assert_eq!( + ascending, reversed, + "the same book must expose the same candidates whatever order its rows arrive in" + ); + // And the retained slice is the ordered prefix, not an arbitrary one. + let mut sorted = ascending.clone(); + sorted.sort(); + assert_eq!(ascending, sorted); +} + +/// A proposal that settles by identity still *reached* whatever else it +/// resembles. `unmatched_book_vouchers` counts only what no proposal came +/// near, so a resembled row must not appear there because another row +/// happened to carry the identity. +#[test] +fn an_identity_match_still_counts_what_it_resembled() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118"), + // Same date, party and amount, different number: resembled, not matched. + BookRow::new("book-2", "20260812", "AA0777"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(only(&report).present_book_key(), Some("book-1")); + assert_eq!( + report.observations().unmatched_book_vouchers, + 0, + "book-2 was resembled even though book-1 carried the identity" + ); +} + +/// The echoed party names are diagnostics a person reads, and a response can +/// drop whole rows but cannot shrink one. The comparison that produced the +/// difference used the full values; only the echo is bounded. +#[test] +fn an_echoed_party_difference_is_bounded() { + let long: &'static str = Box::leak( + format!("Bravo {}", "o".repeat(MAX_OBSERVATION_LABEL_CHARS + 40)).into_boxed_str(), + ); + let names = [ + "Alpha Traders", + long, + "Sales Account", + "Output CGST 9%", + "Output SGST 9%", + ]; + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").party_field(long)]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog_of(&names), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + let party = differences + .iter() + .find(|difference| difference.field == DifferenceField::Party) + .expect("party difference"); + assert_eq!( + party.observed.as_deref().map(|value| value.chars().count()), + Some(MAX_OBSERVATION_LABEL_CHARS) + ); +} diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index f0ccab44..8b7fed9e 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -600,3 +600,90 @@ async fn a_live_shaped_cycle_separates_present_undecided_and_absent() { let observed = simulator.finish().expect("requests"); assert_eq!(observed.len(), 22); } + +/// The admission contract this tool enforces lives in `agent_catalog.rs`, and +/// that file is **not** in the compatibility surface — so an edit confined to +/// it could loosen what a caller may send while the sealed digest and the +/// evidence beneath it stayed unchanged. +/// +/// The numeric bounds are safe already: the schema references constants that +/// live in pinned files. What an unpinned edit could change is the *structure* +/// — dropping `additionalProperties`, widening the numbering enum, removing a +/// required field. So the structure is asserted here, in a pinned file, which +/// makes a silent loosening fail a test rather than pass a seal. +/// +/// Pinning `agent_catalog.rs` instead would also work and is strictly +/// stronger, but it is a shared decision rather than this lane's: that file is +/// edited by every tool change, so pinning it makes every such change reseal, +/// and it would move this PR's `MAX_SURFACE_FILES` arithmetic that the merge +/// order already depends on. +#[test] +fn the_admission_contract_cannot_be_loosened_without_failing_something() { + let definitions = tool_definitions(true, false); + let schema = definitions + .as_array() + .and_then(|tools| tools.iter().find(|tool| tool["name"] == "voucher_presence")) + .expect("voucher_presence tool")["inputSchema"] + .clone(); + let voucher = &schema["properties"]["vouchers"]["items"]; + let numbering = &schema["properties"]["numbering"]["items"]; + + // Nothing undeclared may be sent, at any level. + for object in [ + &schema, + voucher, + numbering, + &voucher["properties"]["entries"]["items"], + ] { + assert_eq!( + object["additionalProperties"], + json!(false), + "an undeclared property would be accepted here" + ); + } + // The three numbering methods are the vocabulary; a fourth would mean the + // crate's `Unknown` fallback silently absorbed it. + assert_eq!( + numbering["properties"]["numbering_method"]["enum"], + json!(["manual", "automatic", "unknown"]) + ); + // A proposal without entries has no magnitude, and one without a date or + // type cannot be placed in a window. + assert_eq!( + voucher["required"], + json!(["date", "voucher_type", "entries"]) + ); + assert_eq!( + numbering["required"], + json!(["voucher_type", "numbering_method"]) + ); + assert_eq!( + voucher["properties"]["entries"]["items"]["required"], + json!(["ledger", "amount"]) + ); + // REMOTEID matching is unreachable from the shipped read, so the input + // stays absent rather than accepted-and-degraded. + assert!(voucher["properties"].get("remote_id").is_none()); + // Every bound the parser relies on is still stated, since the parser reads + // them from here rather than restating them. + for (path, expected) in [ + ( + &voucher["properties"]["voucher_type"]["maxLength"], + agent_import::MAX_MASTER_NAME_CHARS, + ), + ( + &voucher["properties"]["voucher_number"]["maxLength"], + agent_import::MAX_MASTER_NAME_CHARS, + ), + ( + &voucher["properties"]["party"]["maxLength"], + agent_import::MAX_MASTER_NAME_CHARS, + ), + ] { + assert_eq!(path.as_u64(), Some(expected as u64)); + } + assert_eq!( + voucher["properties"]["entries"]["maxItems"].as_u64(), + Some(MAX_PRESENCE_ENTRIES as u64) + ); +} From d12ab072119fa75d1ba7fbc3c3e0542215cda950 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 11 Sep 2026 14:03:31 +0530 Subject: [PATCH 41/91] Take the resemblance union once, and pin the contract's own statement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the review round on b1e1f2b6, each verified against the tree before it was accepted. A number collision returns before resemblance can decide anything, and three of those early returns shipped the bare colliding set. `unmatched_book_vouchers` promises to count rows no proposal "matched or even resembled", so a row the proposal plainly resembled was reported as one nothing came near. This is the third round to find the same defect at a new site, so the fix is not another call site: the union is taken once into the binding every branch already reads, and a redundant guard goes with it. There is no unwrapped form left to pass. Every earlier collision test had the whole window sharing the number, so the reached and resembled sets were the same rows and a bare set looked right. `label` bounds an echoed value at 128 characters while the adapter admits names eight times longer. Two names agreeing across the whole bounded prefix would serialize as one identical pair beside a claim that they differ — the comparison, which runs on the full values, was right and the display contradicted it. The bound now carries a marker inside itself, so the report no longer asserts something false about what it is showing. It cannot recover the missing tail; nothing at this bound can. The admission contract is published from `agent_catalog.rs`, and the adapter deliberately reads its bounds from there rather than restating them. The only independent statement of that contract is the assertion in `agent_presence_tests.rs`, which was not pinned — so a loosened schema and its matching test update, the ordinary unsuspicious pairing, left the surface digest unchanged. Pinning the test binds the statement rather than the file that carries it, and costs a reseal only when that one file changes; pinning `agent_catalog.rs` would put every tool-description edit through one. `MAX_SURFACE_FILES` moves to 214 with that reason named. Pin set diffed: one added, none removed, gate passing. Both code fixes were confirmed to fail on the unfixed source before being kept, and the other 190 core tests pass either way. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 10 +- .../bridge-tally-core/src/book_presence.rs | 28 +++++- .../src/book_presence_tests.rs | 92 +++++++++++++++++++ tools/bridge-tally-compatibility/src/lib.rs | 17 +++- 5 files changed, 140 insertions(+), 9 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 9833a839..b77e5972 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": "b23f6339b68663a7756afb225cb128e9b648705ee677bb2c767739667693f8cb", + "compatibility_surface_sha256": "a12ee1d2b437255c0f2fc7299d25e47c81dcaa10b7436ad28ae44bcf812f5252", "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 83edc843..2818504f 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "5732eb25a61bc2137a77a97753a04367ea5065023b1f6aac618e6106d204f6a3" + "sha256": "ff06e559b6aae1898b2498035baa9851308177157c9417e31ca875682c82b9ec" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -341,6 +341,10 @@ "path": "src-tauri/src/agent_presence.rs", "sha256": "55aafa85117ec701f932a590b00d36913f8e5ced668a5265ae2e31169c4b4cb8" }, + { + "path": "src-tauri/src/agent_presence_tests.rs", + "sha256": "24179621ee7a39ff7e169902b483357f7a13d9f0d25c348b8c29cf52c10e9fe8" + }, { "path": "src-tauri/src/agent_read_profiles.rs", "sha256": "651de79173793b020a3e350f180604b64a8e1946710164f2a3fe31ec6acbb217" @@ -819,7 +823,7 @@ }, { "path": "tools/bridge-tally-compatibility/src/lib.rs", - "sha256": "131cd60600693204faf3889d0b6b6c97ca72cc8381554e9bac9a5ae8d63d839a" + "sha256": "71fd2628599a68f14cba21c6c8d0b0ddf9286e33542ad3f9a4cecc0308e81f44" }, { "path": "tools/bridge-tally-compatibility/src/main.rs", @@ -854,5 +858,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "b23f6339b68663a7756afb225cb128e9b648705ee677bb2c767739667693f8cb" + "manifest_sha256": "a12ee1d2b437255c0f2fc7299d25e47c81dcaa10b7436ad28ae44bcf812f5252" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index d0a85a3b..5b40e4e2 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -1289,8 +1289,13 @@ fn decide( // Manual numbering only decides *within* an observed voucher type: numbers // are a per-type series, so a cross-type match is a resemblance. - if method == NumberingMethod::Manual && type_observed && proposal.number_key.is_some() { - let touched = number_matches.iter().copied().collect::>(); + if method == NumberingMethod::Manual && type_observed && !number_matches.is_empty() { + // Every return below reaches the same rows -- the ones sharing the + // number, plus whatever this proposal resembles -- so the union is + // taken once, here. Taking it per branch is what let three early + // returns ship a bare set, and `unmatched_book_vouchers` then counted + // a plainly resembled row as one no proposal came near. + let touched = with_resemblances(number_matches.iter().copied().collect()); if number_matches.len() > 1 { return shell( PresenceStatus::PossiblyPresent(undecided( @@ -1337,7 +1342,7 @@ fn decide( } return shell( settled(proposal, party, matched, PresenceBasis::ManualVoucherNumber), - with_resemblances(touched), + touched, ); } } @@ -1599,8 +1604,23 @@ fn observe( } /// Bounds an echoed observation label. See `MAX_OBSERVATION_LABEL_CHARS`. +/// Bounds a value echoed back to the caller, and says so when it shortened one. +/// +/// The marker is not decoration. Every comparison upstream runs on the *full* +/// values, so two names differing only past the bound would otherwise serialize +/// as one identical pair sitting beside a claim that they differ. The marker +/// does not recover the distinction -- nothing at this bound can -- but it +/// keeps the report from asserting something false about what it is showing. fn label(value: &str) -> String { - value.chars().take(MAX_OBSERVATION_LABEL_CHARS).collect() + if value.chars().count() <= MAX_OBSERVATION_LABEL_CHARS { + return value.to_string(); + } + let mut bounded: String = value + .chars() + .take(MAX_OBSERVATION_LABEL_CHARS - 1) + .collect(); + bounded.push('\u{2026}'); + bounded } fn keep_strongest( diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 22b48b94..3c5c2e6c 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -2029,6 +2029,11 @@ fn this_contracts_files_are_still_pinned_in_the_compatibility_surface() { for path in [ "src-tauri/crates/bridge-tally-core/src/book_presence.rs", "src-tauri/src/agent_presence.rs", + // The adapter reads its bounds from the published schema rather than + // restating them, so the only independent statement of the admission + // contract is the assertion in this file. Unpinned, a loosened schema + // and its matching test update leave the digest untouched. + "src-tauri/src/agent_presence_tests.rs", ] { assert!( pinned.contains(path), @@ -2231,3 +2236,90 @@ fn an_echoed_party_difference_is_bounded() { Some(MAX_OBSERVATION_LABEL_CHARS) ); } + +/// Bounding must not quietly turn a true difference into a false display. +/// +/// Two accepted names can agree for the whole bounded prefix and differ after +/// it -- the adapter admits names eight times longer than this bound. The +/// comparison sees the difference, so a difference is reported; without a +/// marker both sides then serialize to the same string and the report asserts +/// that two identical values differ. The marker cannot recover the missing +/// tail, but it stops the report from lying about what it is showing. +#[test] +fn a_difference_bounded_on_both_sides_says_the_values_were_shortened() { + let shared = "Bravo ".to_string() + &"o".repeat(MAX_OBSERVATION_LABEL_CHARS); + let proposed: &'static str = Box::leak(format!("{shared} Northern Division").into_boxed_str()); + let observed: &'static str = Box::leak(format!("{shared} Southern Division").into_boxed_str()); + let names = [ + "Alpha Traders", + proposed, + "Sales Account", + "Output CGST 9%", + "Output SGST 9%", + ]; + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").party_field(observed)]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .party(proposed) + .build()]; + let report = run( + &window, + &catalog_of(&names), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + let party = differences + .iter() + .find(|difference| difference.field == DifferenceField::Party) + .expect("the full values differ, so a difference is reported"); + let shown_proposed = party.proposed.as_deref().expect("proposed"); + let shown_observed = party.observed.as_deref().expect("observed"); + // The premise: bounding really does collapse these two onto one string. + assert_eq!( + shown_proposed, shown_observed, + "the values agree across the whole bounded prefix" + ); + for shown in [shown_proposed, shown_observed] { + assert!( + shown.ends_with('\u{2026}'), + "a shortened value must say it was shortened" + ); + assert_eq!( + shown.chars().count(), + MAX_OBSERVATION_LABEL_CHARS, + "the marker is inside the bound, not added to it" + ); + } +} + +/// A collision returns before resemblance can decide anything, but the +/// proposal still *reached* what it resembles. Every earlier collision test +/// had the whole window sharing the number, so the colliding set and the +/// resembled set were the same rows and a bare set looked correct. +#[test] +fn a_number_collision_still_reaches_what_it_only_resembled() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118"), + // Shares the number: a collision, and the reason this returns early. + BookRow::new("book-2", "20260812", "AA0118"), + // Shares date, party and amount but not the number: resembled only, + // and reachable solely through the resemblance scan the early return + // used to skip. + BookRow::new("book-3", "20260812", "AA0777"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(reason(only(&report)), UndecidedReason::BookNumberCollision); + assert_eq!( + report.observations().unmatched_book_vouchers, + 0, + "book-3 was plainly resembled; the collision must not hide that" + ); +} diff --git a/tools/bridge-tally-compatibility/src/lib.rs b/tools/bridge-tally-compatibility/src/lib.rs index 22f2758e..12f73073 100644 --- a/tools/bridge-tally-compatibility/src/lib.rs +++ b/tools/bridge-tally-compatibility/src/lib.rs @@ -48,7 +48,22 @@ pub const RESERVED_SURFACE_FILES: usize = 15; /// the reverse, dropping one silently, while the surface digest and the /// evidence attesting the reads beneath both stayed unchanged. Two files for /// one named reason, one per surface; still not headroom. -pub const MAX_SURFACE_FILES: usize = 213; +/// +/// Raised again from 213 to 214 to admit +/// `src-tauri/src/agent_presence_tests.rs`. The admission contract for +/// `voucher_presence` -- which properties are accepted, their bounds, and the +/// refusal of anything undeclared -- is published from `agent_catalog.rs`, and +/// `agent_presence.rs` deliberately *reads* those bounds rather than restating +/// them. Loosening the published schema therefore changes what the tool +/// admits without touching a pinned file. The one place the contract is +/// stated independently is the assertion in this test, so a schema loosened +/// together with its corresponding test update -- the normal, unsuspicious +/// pairing -- would otherwise leave the digest unchanged and let existing +/// evidence attest an admission contract it never covered. Pinning +/// `agent_catalog.rs` instead would put every tool-description edit in the +/// repository through a reseal; this pin binds the statement of the contract +/// rather than the file that happens to carry it. One file, one named reason. +pub const MAX_SURFACE_FILES: usize = 214; pub const MAX_OPERATIONS: usize = 16; pub const MAX_CLAIMS: usize = 128; pub const MAX_KEYS: usize = 32; From 5d92e4296d064b0b8143638fb1f150737e44c105 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 11 Sep 2026 14:46:53 +0530 Subject: [PATCH 42/91] Keep the shortening marker from costing a character of content The marker added in b1e1f2b6 was fitted *inside* `MAX_OBSERVATION_LABEL_CHARS` by taking one fewer character of the value. That looks like bookkeeping and is not: two values differing at exactly character 128 were visible in the echo before the marker existed and became identical after it. Making the report honest about truncation moved the boundary of what can be seen one position earlier. It is also quieter than the bug it replaced, which is why it survived review and a green suite. The original defect asserted two identical values differ -- plainly self-contradictory. This one asserts they differ *and* that both were shortened; both statements are true, the pair still serializes as one string, and a before/after comparison reads as a strict improvement. So the marker now sits outside the bound. The constant bounds the echoed value, and one character of annotation on top of it bounds nothing worth bounding; the two existing length assertions move to `+ 1` and say why. `the_shortening_marker_does_not_cost_a_character_of_content` fails on the source before this commit, confirmed by reverting rather than by assuming. Found by running a peer's question over my own last round of fixes: did either of them move a failure somewhere quieter instead of removing it. The other fix in that round, the resemblance union, came back clean -- its set feeds only `unmatched_book_vouchers`, never a status, and it moves that count down from an over-count toward correct. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/book_presence.rs | 19 ++++-- .../src/book_presence_tests.rs | 61 +++++++++++++++++-- 4 files changed, 74 insertions(+), 12 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index b77e5972..bb9e9be3 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": "a12ee1d2b437255c0f2fc7299d25e47c81dcaa10b7436ad28ae44bcf812f5252", + "compatibility_surface_sha256": "fa0712facbdd86e70c0efdbd05566ee2c4397bc0d3c16fa5f8de02e53f01c2f2", "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 2818504f..8b334334 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "ff06e559b6aae1898b2498035baa9851308177157c9417e31ca875682c82b9ec" + "sha256": "6d304ee60be2ea5c020a714c55cc4dd572ac04660cd10e19c47350e618832baf" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -858,5 +858,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "a12ee1d2b437255c0f2fc7299d25e47c81dcaa10b7436ad28ae44bcf812f5252" + "manifest_sha256": "fa0712facbdd86e70c0efdbd05566ee2c4397bc0d3c16fa5f8de02e53f01c2f2" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 5b40e4e2..cdb18bfc 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -62,6 +62,9 @@ pub const MAX_BOOK_KEY_CHARS: usize = 128; /// two fields are **recognition labels**, not keys: a group's identity is its /// `book_keys`, which are bounded by count. pub const MAX_OBSERVATION_LABEL_CHARS: usize = 128; +/// Appended to an echoed value that was longer than its bound, so a reader can +/// tell a shortened value from a whole one. See `label`. +pub const SHORTENED: char = '\u{2026}'; /// Longest accepted text field, in characters. This bounds pathological input; /// it is not a claim about what Tally accepts. pub const MAX_TEXT_CHARS: usize = 16_384; @@ -1611,15 +1614,21 @@ fn observe( /// as one identical pair sitting beside a claim that they differ. The marker /// does not recover the distinction -- nothing at this bound can -- but it /// keeps the report from asserting something false about what it is showing. +/// +/// It is appended *outside* `MAX_OBSERVATION_LABEL_CHARS` rather than taking a +/// character of content to make room, and that is deliberate. Spending a +/// character would make two values differing at exactly the bound serialize +/// identically -- turning a difference that was visible before this marker +/// existed into one that is not, which is the failure the marker is here to +/// prevent, reintroduced one position earlier. The constant bounds the echoed +/// *value*; one character of annotation on top of it bounds nothing worth +/// bounding. fn label(value: &str) -> String { if value.chars().count() <= MAX_OBSERVATION_LABEL_CHARS { return value.to_string(); } - let mut bounded: String = value - .chars() - .take(MAX_OBSERVATION_LABEL_CHARS - 1) - .collect(); - bounded.push('\u{2026}'); + let mut bounded: String = value.chars().take(MAX_OBSERVATION_LABEL_CHARS).collect(); + bounded.push(SHORTENED); bounded } diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 3c5c2e6c..7ed15668 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1992,8 +1992,10 @@ fn book_observation_labels_are_bounded() { let group = &report.observations().duplicate_numbers[0]; assert_eq!( group.voucher_number.chars().count(), - MAX_OBSERVATION_LABEL_CHARS + MAX_OBSERVATION_LABEL_CHARS + 1, + "the whole bound of content, plus the marker that says it was applied" ); + assert!(group.voucher_number.ends_with(SHORTENED)); // The group's identity is its keys, which are bounded by count, not by the // label that helps a human recognise it. assert_eq!(group.book_keys, vec!["book-1", "book-2"]); @@ -2233,10 +2235,61 @@ fn an_echoed_party_difference_is_bounded() { .expect("party difference"); assert_eq!( party.observed.as_deref().map(|value| value.chars().count()), - Some(MAX_OBSERVATION_LABEL_CHARS) + // The bound, plus the one character that says it was applied. + Some(MAX_OBSERVATION_LABEL_CHARS + 1) ); } +/// The marker must not cost a character of content. +/// +/// Spending one to stay inside the bound would make two values differing at +/// exactly the bound serialize identically — converting a difference that was +/// visible before the marker existed into one that is not. That is the failure +/// the marker exists to prevent, reintroduced one position earlier, and it +/// would be quieter than the bug it replaced: the report would still say the +/// two differ, and now also say it had shortened them, while showing one +/// string. Both are true statements and the reader still cannot see it. +#[test] +fn the_shortening_marker_does_not_cost_a_character_of_content() { + let shared = "o".repeat(MAX_OBSERVATION_LABEL_CHARS - 1); + // Identical for the whole bound but the final character inside it. + let proposed: &'static str = Box::leak(format!("{shared}A tail").into_boxed_str()); + let observed: &'static str = Box::leak(format!("{shared}B tail").into_boxed_str()); + let names = [ + "Alpha Traders", + proposed, + "Sales Account", + "Output CGST 9%", + "Output SGST 9%", + ]; + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").party_field(observed)]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .party(proposed) + .build()]; + let report = run( + &window, + &catalog_of(&names), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + let party = differences + .iter() + .find(|difference| difference.field == DifferenceField::Party) + .expect("party difference"); + assert_ne!( + party.proposed, party.observed, + "a difference inside the bound must still be visible in the echo" + ); + for shown in [&party.proposed, &party.observed] { + assert!(shown + .as_deref() + .is_some_and(|value| value.ends_with(SHORTENED))); + } +} + /// Bounding must not quietly turn a true difference into a false display. /// /// Two accepted names can agree for the whole bounded prefix and differ after @@ -2288,8 +2341,8 @@ fn a_difference_bounded_on_both_sides_says_the_values_were_shortened() { ); assert_eq!( shown.chars().count(), - MAX_OBSERVATION_LABEL_CHARS, - "the marker is inside the bound, not added to it" + MAX_OBSERVATION_LABEL_CHARS + 1, + "the whole bound of content, plus the marker" ); } } From 47491dd5d14bc84855f964a07c438d197d397328 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 11 Sep 2026 15:01:25 +0530 Subject: [PATCH 43/91] Refuse a short catalogue, a late party parse, and an unpinned schema leaf Three findings from the review round on 5d92e429, each reproduced before it was accepted. **A ledger the catalogue never listed could authorise a duplicate.** Both paired catalogue reads agreeing proves only that they agree; if they consistently omit a ledger the book posts to, set-equality still passes. A proposal naming that ledger then binds `Unmatched`, so every party rule declines to run, and if the existing voucher differs in date and number an `Absent` is issued off a comparison that was never possible -- the duplicate this contract exists to prevent, arrived at through the one door left open. The voucher window is independent evidence about which ledgers exist and was already in hand, so it now cross-checks the catalogue and fails closed with `ledger_catalogue_incomplete`. No new read, and the catalogue side of the cardinality question is closed by data already fetched. **The pinned admission test enumerated leaves, and the list was short.** It asserted the `voucher_type`, `voucher_number` and `party` bounds and not `entries.items.properties.amount.maxLength`, which `enforce_published_schema` reads at runtime -- so that bound could move from 64 to 10, admission could change, and every assertion still passed with no pinned byte altered. Verified by making exactly that edit: the descriptive test passes while the tool admits something else. An enumerated list of leaves goes stale; a digest over the whole published schema cannot, so the contract is now pinned by one. The descriptive assertions stay, because they say what the contract *means* and a digest says nothing. Changing the schema now forces the constant to change, which moves the surface digest, which is the visibility the seal is for. **A malformed party cost three Tally reads.** A party's entity shape is decided entirely by the caller's text, but the parse that refuses it lives inside `PresenceRequest::new`, after company verification, two catalogue reads and the full window read. Measured: 58,744 bytes for an input that was always going to be refused. It is parsed with the other cross-input refusals now. My first test for that last one was worthless and passed with the guard removed: against an offline endpoint a failed connection also spends zero bytes, so it could not tell "refused before reading" from "the read did not work". It runs against the live simulator now, where zero bytes means the refusal really did come first. Every guard here was removed and re-run to confirm its test fails without it. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 8 +- .../bridge-tally-core/src/book_presence.rs | 12 ++ src-tauri/src/agent_presence.rs | 34 +++- src-tauri/src/agent_presence_tests.rs | 156 +++++++++++++++++- 5 files changed, 197 insertions(+), 15 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index bb9e9be3..0b67a067 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": "fa0712facbdd86e70c0efdbd05566ee2c4397bc0d3c16fa5f8de02e53f01c2f2", + "compatibility_surface_sha256": "5110250d51dc136fa6da1f28db33d2b8f7dfc171d740fd3578d2ac0f32d14eac", "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 8b334334..d08e5083 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "6d304ee60be2ea5c020a714c55cc4dd572ac04660cd10e19c47350e618832baf" + "sha256": "b2f69a1c183aee826054963f0bd0cd784e261bc9a2174e01c1bae0965e43dccd" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -339,11 +339,11 @@ }, { "path": "src-tauri/src/agent_presence.rs", - "sha256": "55aafa85117ec701f932a590b00d36913f8e5ced668a5265ae2e31169c4b4cb8" + "sha256": "fb5cbf31550c67e78f468784f0385aa2a7bd47f75998c4e891f5cabed26b155e" }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "24179621ee7a39ff7e169902b483357f7a13d9f0d25c348b8c29cf52c10e9fe8" + "sha256": "46e3c18f6bfca00dbcb7dbe2d51aa48aae4ccdfc6429f8306a408e21b99ad53f" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -858,5 +858,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "fa0712facbdd86e70c0efdbd05566ee2c4397bc0d3c16fa5f8de02e53f01c2f2" + "manifest_sha256": "5110250d51dc136fa6da1f28db33d2b8f7dfc171d740fd3578d2ac0f32d14eac" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index cdb18bfc..1b588ae2 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -370,6 +370,18 @@ impl ProposedVoucher { pub fn voucher_type(&self) -> &str { &self.voucher_type } + + /// The party name as the source document spelled it, and this proposal's + /// place in the batch. An adapter that wants to refuse a malformed party + /// *before* it spends a read needs both, because the entity parse that + /// would refuse it otherwise happens inside `PresenceRequest::new`. + pub fn party(&self) -> Option<&str> { + self.party.as_deref() + } + + pub fn position(&self) -> usize { + self.position + } } /// One observed window of a company's book. It can only be constructed from a diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs index 5faf83ab..19d259e0 100644 --- a/src-tauri/src/agent_presence.rs +++ b/src-tauri/src/agent_presence.rs @@ -13,7 +13,7 @@ use bridge_tally_core::book_presence::{ ObservedVoucher, PresenceError, PresenceReport, PresenceRequest, ProposedVoucher, ProposedVoucherInput, RemoteIdEvidence, WindowRead, }; -use bridge_tally_core::master_binding::{MasterCatalog, MasterClass}; +use bridge_tally_core::master_binding::{MasterCatalog, MasterClass, SourceEntity}; /// Most vouchers one presence request may propose. The window read is /// unaffected by this: it always reads its whole range. @@ -65,6 +65,16 @@ impl Server { // enforces them again at its own boundary; this only stops a request // that was always going to be refused from exercising the endpoint. for proposal in &proposals { + // A party's *entity shape* -- how many identifiers its name + // carries -- is decided entirely by the caller's text, and the + // crate parses it inside `PresenceRequest::new`, three reads + // later. Parsing it here keeps the promise the refusal path + // already makes everywhere else: an input this tool was always + // going to reject costs no Tally read. + if let Some(party) = proposal.party() { + SourceEntity::new(proposal.position(), party) + .map_err(|error| error.safe_reason_code().to_string())?; + } if proposal.date() < from.as_str() || proposal.date() > to.as_str() { return Err(PresenceError::WindowDoesNotCover .safe_reason_code() @@ -142,6 +152,28 @@ impl Server { return Err("ledger_snapshot_drifted".to_string().into()); } + // The window is independent evidence about which ledgers exist, + // and it is already in hand. A ledger the book posts to but the + // catalogue never listed proves the catalogue short -- both reads + // agreeing only proves they agree. Left unchecked, a proposal + // naming that ledger binds `Unmatched`, every party rule declines + // to run, and an `Absent` is authorised off a comparison that was + // never possible. That is the failure this whole contract exists + // to prevent, so it fails closed here rather than being reported. + for row in &rows { + let entry_ledgers = row["amounts"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(|entry| entry["ledger"].as_str()); + for ledger in row["party"].as_str().into_iter().chain(entry_ledgers) { + if catalog.exact(ledger).is_none() { + return Err("ledger_catalogue_incomplete".to_string().into()); + } + } + } + let observed = rows .iter() .map(book_voucher) diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index 8b7fed9e..cf6a3fb4 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -490,15 +490,9 @@ fn paired_read(payload: &str) -> Vec { ] } -fn presence_plans() -> Vec { - let catalogue = catalogue_xml(); - let mut steps = vec![Step::Company, Step::Status, Step::Company, Step::Status]; - // Catalogue, then the voucher window, then the catalogue again: the - // verdict is built from two observations and the second read proves the - // first still holds. - steps.extend(paired_read(&catalogue)); - steps.extend(paired_read(&window_xml())); - steps.extend(paired_read(&catalogue)); +/// Turns the step list into simulator plans. Separated from `presence_plans` +/// so a test that varies one payload does not restate the framing of all six. +fn plans(steps: Vec) -> Vec { steps .into_iter() .map(|step| match step { @@ -516,6 +510,18 @@ fn presence_plans() -> Vec { .collect() } +fn presence_plans() -> Vec { + let catalogue = catalogue_xml(); + let mut steps = vec![Step::Company, Step::Status, Step::Company, Step::Status]; + // Catalogue, then the voucher window, then the catalogue again: the + // verdict is built from two observations and the second read proves the + // first still holds. + steps.extend(paired_read(&catalogue)); + steps.extend(paired_read(&window_xml())); + steps.extend(paired_read(&catalogue)); + plans(steps) +} + #[tokio::test] async fn a_live_shaped_cycle_separates_present_undecided_and_absent() { let simulator = SequenceSimulator::spawn(presence_plans()).expect("simulator"); @@ -617,6 +623,37 @@ async fn a_live_shaped_cycle_separates_present_undecided_and_absent() { /// edited by every tool change, so pinning it makes every such change reseal, /// and it would move this PR's `MAX_SURFACE_FILES` arithmetic that the merge /// order already depends on. +#[test] +fn every_admission_leaf_is_pinned_by_this_digest() { + // The assertions below this one say what the contract *means*, and they + // are worth reading. They cannot be complete: the parser drives itself + // from the published schema, so every leaf in it is admission-relevant, + // and a review found the previous version silently omitting + // `entries.items.properties.amount.maxLength` among others. Enumerating + // leaves is a list that goes stale; a digest over the whole schema cannot. + // + // This file is pinned into the compatibility surface, so changing the + // schema now forces this constant to change, which moves the surface + // digest, which is exactly the visibility the seal is for. If this fails + // and the schema change was deliberate, update the constant *and* reseal + // — that pairing is the point, not an inconvenience. + const PINNED: &str = "6b2f7f67269beaf40631057eeb3ccd563360239393129dc082c0755b5ff3a31c"; + let definitions = tool_definitions(true, false); + let schema = definitions + .as_array() + .and_then(|tools| tools.iter().find(|tool| tool["name"] == "voucher_presence")) + .expect("voucher_presence tool")["inputSchema"] + .clone(); + // `serde_json::Value` orders object keys, so this is canonical already, + // and `sha256_json` is the digest this module already uses for evidence. + let digest = sha256_json(&schema); + assert_eq!( + digest, PINNED, + "the published admission contract changed; update this digest in the same commit that \ + reseals the compatibility surface" + ); +} + #[test] fn the_admission_contract_cannot_be_loosened_without_failing_something() { let definitions = tool_definitions(true, false); @@ -687,3 +724,104 @@ fn the_admission_contract_cannot_be_loosened_without_failing_something() { Some(MAX_PRESENCE_ENTRIES as u64) ); } + +/// A ledger the book posts to that the catalogue never listed proves the +/// catalogue short. Both catalogue reads agreeing only proves they agree. +/// +/// Left unchecked this is the quiet failure: a proposal naming that ledger +/// binds `Unmatched`, so every party rule declines to run, and an `Absent` +/// gets authorised off a comparison that was never possible — which is the +/// duplicate this whole contract exists to prevent. +#[tokio::test] +async fn a_ledger_missing_from_the_catalogue_fails_closed() { + let unlisted = window_xml().replace("WR2 Sales", "WR2 Sales Not In Catalogue"); + let catalogue = catalogue_xml(); + let mut steps = vec![Step::Company, Step::Status, Step::Company, Step::Status]; + steps.extend(paired_read(&catalogue)); + steps.extend(paired_read(&unlisted)); + steps.extend(paired_read(&catalogue)); + let simulator = SequenceSimulator::spawn(plans(steps)).expect("simulator"); + let directory = tempfile::tempdir().expect("directory"); + let server = Server::new(Settings { + endpoint: TallyEndpointConfig { + host: simulator.address().ip().to_string(), + port: simulator.address().port(), + }, + data_dir: directory.path().to_path_buf(), + max_rows: 500, + max_bytes: 200_000, + redaction: Redaction::None, + import_enabled: false, + writes_enabled: false, + }); + let response = server + .call_tool_response( + "voucher_presence", + json!({ + "company_guid": CAPTURED_GUID, + "from": "20260901", + "to": "20260930", + "numbering": [{"voucher_type":"Journal","numbering_method":"manual"}], + // Dated and numbered away from both book rows, so nothing but + // the party could have surfaced them. Without the guard this + // returns `absent` and a caller imports a second copy. + "vouchers": [proposal("JV-77", "WR2 Sales Not In Catalogue", "12.50")], + }), + ) + .await; + assert_eq!( + response.value["structuredContent"]["result"]["error"]["code"], + "ledger_catalogue_incomplete" + ); +} + +/// A party's entity shape is decided entirely by the caller's text, so the +/// tool must refuse it before spending a read — the promise every other +/// argument refusal on this tool already keeps. +/// +/// The endpoint here is a *live simulator*, deliberately. Against an offline +/// server this assertion passes whether or not the guard exists, because a +/// failed connection also spends no bytes: the test could not tell "refused +/// before reading" from "the read did not work". With reads available, zero +/// bytes means the refusal really did come first. +#[tokio::test] +async fn a_party_with_too_many_identifiers_costs_no_read() { + let party = (1..=33) + .map(|index| format!("{:08}", 10_000_000 + index)) + .collect::>() + .join(" "); + let simulator = SequenceSimulator::spawn(presence_plans()).expect("simulator"); + let directory = tempfile::tempdir().expect("directory"); + let server = Server::new(Settings { + endpoint: TallyEndpointConfig { + host: simulator.address().ip().to_string(), + port: simulator.address().port(), + }, + data_dir: directory.path().to_path_buf(), + max_rows: 500, + max_bytes: 200_000, + redaction: Redaction::None, + import_enabled: false, + writes_enabled: false, + }); + let response = server + .call_tool_response( + "voucher_presence", + json!({ + "company_guid": CAPTURED_GUID, + "from": "20260901", + "to": "20260930", + "numbering": [{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers": [proposal("JV-1", &party, "12.50")], + }), + ) + .await; + assert_eq!( + response.value["structuredContent"]["result"]["error"]["code"], + "master_identifiers_too_many" + ); + assert_eq!( + response.value["structuredContent"]["evidence"]["bytes"], 0, + "an input that was always going to be refused must cost no read" + ); +} From e19d9a5138d82879ecc9ca0c91f801d15721c6d7 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 04:03:08 +0530 Subject: [PATCH 44/91] Replay the engagement against a live book, and stop observations costing it Two things, and the first is the evidence the open P1 asked for. **The twenty-invoice replay ran against a licensed TallyPrime 7.1 Silver book** and passed. Fifteen invoices the book already holds came back `present` by `manual_voucher_number` with no differences; one short by the engagement's 36.13 came back `present` *with an amount difference* -- the finding that engagement needed and nobody had asked for; four new-customer invoices came back `absent`. Requested 20, present 16, absent 4, undecided 0, over a window the read reported as 82 vouchers with 66 nothing proposed came near. The harness is `#[ignore]`d, environment-driven and **read-only**. Seeding a book would need a disposable company and this MCP cannot create one, so the proposals are built from the book's own rows: the present ones are present by construction and no voucher is posted to make them so. It emits counts, bases and reason codes only -- never a name, number or amount -- because this repository is public and a pasted failure message is how real values escape. One narrowing it cannot avoid, stated in the test rather than only here: the engagement's shortfall was in the *book*, and read-only the harness puts it on the proposal side. The difference to detect is the same one; which side is missing the head is reversed. **Observations could cost a successful report.** `book` is a sibling of `items`, so `fit_response` cannot trim it, and the final framing serializes the whole payload twice -- once as `structuredContent`, once as text. At the crate's documented maxima (twenty-five duplicate groups of ten 128-character keys, twenty-five unbalanced keys, labels at their bound, all of which count *characters* and so admit four-byte ones) `book` clears the default cap on its own, and the report is replaced by `agent_response_too_large` after all three Tally reads are paid for. A diagnostic must not cost the answer. So the listing is trimmed a row at a time against a budget, and says when it was. Twenty of twenty-five groups is worth more to a reader than none, and the counts beside them stay exact either way. The row-by-row part is asserted on both sides -- a wholesale drop would pass a laxer test, and that laxer test is what I wrote first. The trimming approach here is not mine: it is the better half of work rescued from an unowned worktree, which degraded gracefully where my version dropped every listing. The premise-first test is mine. Pin set read off the branch at reseal: none added, none removed, gate passing. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 6 +- src-tauri/src/agent_presence.rs | 62 +++- src-tauri/src/agent_presence_tests.rs | 344 ++++++++++++++++++ 4 files changed, 408 insertions(+), 6 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 0b67a067..97393a8b 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": "5110250d51dc136fa6da1f28db33d2b8f7dfc171d740fd3578d2ac0f32d14eac", + "compatibility_surface_sha256": "8b48f8f3bf66f52b41603ea85d7f25e4c2354b0fbdae31de2258e4e978666e97", "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 d08e5083..137af6e9 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -339,11 +339,11 @@ }, { "path": "src-tauri/src/agent_presence.rs", - "sha256": "fb5cbf31550c67e78f468784f0385aa2a7bd47f75998c4e891f5cabed26b155e" + "sha256": "1451cc20c41ea5b7dde1de8ca1bb1d42bdc96a1f8bbbee178185d9b15d4f5efa" }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "46e3c18f6bfca00dbcb7dbe2d51aa48aae4ccdfc6429f8306a408e21b99ad53f" + "sha256": "886914f5848fb0d27417f289816eec5ba773b11ae17ac351a000eb190b12b3be" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -858,5 +858,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "5110250d51dc136fa6da1f28db33d2b8f7dfc171d740fd3578d2ac0f32d14eac" + "manifest_sha256": "8b48f8f3bf66f52b41603ea85d7f25e4c2354b0fbdae31de2258e4e978666e97" } \ No newline at end of file diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs index 19d259e0..d9957ad5 100644 --- a/src-tauri/src/agent_presence.rs +++ b/src-tauri/src/agent_presence.rs @@ -20,6 +20,19 @@ use bridge_tally_core::master_binding::{MasterCatalog, MasterClass, SourceEntity pub(super) const MAX_PRESENCE_VOUCHERS: usize = 500; /// Most voucher types one numbering declaration may name. pub(super) const MAX_PRESENCE_VOUCHER_TYPES: usize = 50; +/// Share of the byte cap the fixed observations may occupy. +/// +/// `fit_response` can trim only `items`; `book` is a sibling it cannot reach, +/// and the final framing serializes the whole payload **twice** -- once as +/// `structuredContent` and again as text for clients that read only that. So a +/// maximal `book` (twenty-five duplicate-number groups of ten 128-character +/// keys, twenty-five unbalanced keys, labels at their bound) runs to six +/// figures on its own, and the doubled envelope clears the default cap without +/// a single large item. The report would then be discarded wholesale *after* +/// all three Tally reads were paid for. +/// +/// An eighth leaves the doubled observations at a quarter of the cap. +const OBSERVATION_BUDGET_DIVISOR: usize = 8; /// Most ledger entries one proposed voucher may carry. pub(super) const MAX_PRESENCE_ENTRIES: usize = 200; /// Enforces the published `inputSchema` on this tool's nested arrays. @@ -189,7 +202,14 @@ impl Server { let request = PresenceRequest::new(&window, &catalog, &numbering, &proposals) .map_err(presence_code)?; let report = book_presence::assess(&request); - let (result, truncated) = presence_result(&report, &catalogue, reason, offset, limit); + let (result, truncated) = presence_result( + &report, + &catalogue, + reason, + offset, + limit, + self.settings.max_bytes, + ); Ok(ToolOutcome { payload: json!({ @@ -315,12 +335,47 @@ fn parse_proposals(args: &Value) -> Result, String> { Ok(parsed) } +/// Bounds the fixed observations, so a diagnostic can never cost the answer. +/// +/// The counts are what a person acts on; the listed keys are a convenience for +/// finding the rows again. When the listing will not fit, the listing goes and +/// every count stays -- and the report says so, because a list that is shorter +/// than it claims is the defect this contract keeps finding elsewhere. +fn bounded_observations(mut book: Value, budget: usize) -> Value { + // Drop one listed row at a time rather than the whole listing. Twenty of + // twenty-five duplicate groups is worth more to the person reading this + // than none of them, and the counts beside them stay exact either way. + let mut withheld = false; + while book.to_string().len() > budget { + let dropped = book["duplicate_numbers"] + .as_array_mut() + .and_then(Vec::pop) + .inspect(|_| book["duplicate_numbers_truncated"] = json!(true)) + .or_else(|| { + book["unbalanced_vouchers"] + .as_array_mut() + .and_then(Vec::pop) + }); + if dropped.is_none() { + // Only counts and flags are left; they are the part a reader + // reconciles against, so they are never dropped. + break; + } + withheld = true; + } + if withheld { + book["listings_withheld_for_size"] = json!(true); + } + book +} + fn presence_result( report: &PresenceReport, catalogue: &[String], corroboration_reason: Option<&'static str>, offset: usize, limit: usize, + max_bytes: usize, ) -> (Value, bool) { let (from, to) = report.window(); let total = report.vouchers().len(); @@ -346,7 +401,10 @@ fn presence_result( "offset": offset, "total": total, "totals": report.totals(), - "book": report.observations(), + "book": bounded_observations( + serde_json::to_value(report.observations()).unwrap_or_default(), + max_bytes / OBSERVATION_BUDGET_DIVISOR, + ), "catalogue_evidence_sha256": sha256_json(&catalogue.to_vec()), }); (result, truncated) diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index cf6a3fb4..80450dc2 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -825,3 +825,347 @@ async fn a_party_with_too_many_identifiers_costs_no_read() { "an input that was always going to be refused must cost no read" ); } +/// The observations can outgrow the byte cap on their own, and `fit_response` +/// cannot reach them - it trims `items`, and `book` is a sibling. Worse, the +/// final framing serializes the payload twice, so the real cost is doubled. +/// +/// The premise is asserted first: a `book` at the crate's documented maxima +/// really does exceed the budget. Without that, the degradation below would be +/// a control whose branch never fires. +#[test] +fn a_maximal_book_degrades_to_its_counts_rather_than_costing_the_report() { + // Both bounds count *characters*, so the widest value they admit is a + // four-byte one. A real Tally GUID is 36 ASCII bytes and nowhere near + // this; the guard exists because the contract permits this, not because + // the common case needs it. + let key = "\u{1f600}".repeat(book_presence::MAX_BOOK_KEY_CHARS); + let label = "\u{1f600}".repeat(book_presence::MAX_OBSERVATION_LABEL_CHARS); + let groups = (0..book_presence::MAX_DUPLICATE_NUMBER_GROUPS) + .map(|_| { + json!({ + "voucher_type": label, "voucher_number": label, + "book_keys": (0..book_presence::MAX_KEYS_PER_DUPLICATE_GROUP) + .map(|_| key.clone()).collect::>(), + "book_voucher_count": 10, + }) + }) + .collect::>(); + let book = json!({ + "duplicate_numbers": groups, + "duplicate_number_group_count": book_presence::MAX_DUPLICATE_NUMBER_GROUPS, + "duplicate_numbers_truncated": false, + "unbalanced_vouchers": (0..book_presence::MAX_UNBALANCED_LISTED) + .map(|_| key.clone()).collect::>(), + "unbalanced_voucher_count": book_presence::MAX_UNBALANCED_LISTED, + "unmatched_book_vouchers": 0, "window_voucher_count": 20_000, + "remote_id_observed": false, + }); + let budget = 200_000 / OBSERVATION_BUDGET_DIVISOR; + let full = book.to_string().len(); + assert!( + full > budget, + "the premise fails: a maximal book is {full} bytes against a budget of {budget}" + ); + assert!( + full * 2 > 200_000, + "the doubled envelope should clear the default cap on observations alone" + ); + + let bounded = bounded_observations(book, budget); + assert!(bounded.to_string().len() <= budget); + // Every count survives, and the listing keeps as many rows as fit rather + // than emptying: dropping the lot would satisfy a laxer assertion than + // this one, so the retained count is bounded on both sides. + let listed = bounded["duplicate_numbers"] + .as_array() + .expect("listed") + .len(); + assert!( + listed < book_presence::MAX_DUPLICATE_NUMBER_GROUPS, + "nothing was trimmed" + ); + assert!( + listed > 0, + "the whole listing was dropped rather than trimmed" + ); + assert_eq!(bounded["listings_withheld_for_size"], json!(true)); + assert_eq!(bounded["duplicate_numbers_truncated"], json!(true)); + assert_eq!( + bounded["duplicate_number_group_count"], + json!(book_presence::MAX_DUPLICATE_NUMBER_GROUPS) + ); + assert_eq!(bounded["window_voucher_count"], json!(20_000)); + + // A book that fits comes back untouched, with no marker added. + let small = json!({"duplicate_numbers": [], "window_voucher_count": 3}); + assert_eq!(bounded_observations(small.clone(), budget), small); + + // And a book that is over by a little keeps most of its listing rather + // than losing all of it -- the row-by-row part, which a wholesale drop + // would pass the assertions above without ever doing. + let rows = (0..40).map(|_| json!(key)).collect::>(); + let large = json!({"duplicate_numbers": [], "unbalanced_vouchers": rows, + "unbalanced_voucher_count": 40, "window_voucher_count": 40}); + let kept = bounded_observations(large, 12_000); + let listed = kept["unbalanced_vouchers"] + .as_array() + .expect("listed") + .len(); + assert!( + (1..40).contains(&listed), + "expected a partial listing, kept {listed} of 40" + ); + assert_eq!(kept["unbalanced_voucher_count"], json!(40)); + assert_eq!(kept["listings_withheld_for_size"], json!(true)); +} + +// --------------------------------------------------------------------------- +// Live replay — manual, owner-authorized, and read-only. +// +// The synthetic cycle above verifies the rules against data this repository +// invented. This replays the shape of the engagement that motivated the +// capability against a real book: twenty proposed invoices, most of which the +// book already holds, one of them differing in amount. +// +// Two properties make it safe to keep in a public repository: +// +// * It **never writes.** The proposals are built from the book's own rows, +// so the "already present" ones are present by construction and no voucher +// is posted to produce them. That inverts one detail of the original +// engagement and the assertion says so. +// * It **emits no book content** — counts, bases and reason codes only. A +// failure prints what went wrong, never a party name, number or amount. +// --------------------------------------------------------------------------- + +/// How many faithful copies to propose, how many to perturb, how many to invent. +const REPLAY_PRESENT: usize = 15; +const REPLAY_DIFFERING: usize = 1; +const REPLAY_ABSENT: usize = 4; +/// The engagement's invoice was posted 36.13 short of its source document. +const SHORT_BY_PAISE: i64 = 3_613; +/// A party the book has never seen, so nothing it proposes can resemble a row +/// by party. Fabricated, and it must stay that way. +const REPLAY_UNKNOWN_PARTY: &str = "Bridge Replay Unknown Party"; + +fn live_env(key: &str) -> String { + std::env::var(key).unwrap_or_else(|_| panic!("{key} must be set for the live replay")) +} + +/// Replays the twenty-invoice engagement against the live lab. +/// +/// ```text +/// BRIDGE_TALLY_LIVE_PORT=9001 \ +/// BRIDGE_TALLY_LIVE_COMPANY_GUID= \ +/// BRIDGE_PRESENCE_LIVE_FROM=YYYYMMDD BRIDGE_PRESENCE_LIVE_TO=YYYYMMDD \ +/// cargo test -p bridge --lib replay_the_twenty_invoice_engagement -- --ignored --nocapture +/// ``` +#[tokio::test] +#[ignore = "manual owner-authorized live read; needs the lab reachable on the given port"] +async fn replay_the_twenty_invoice_engagement() { + let port = live_env("BRIDGE_TALLY_LIVE_PORT") + .parse::() + .expect("numeric port"); + let guid = live_env("BRIDGE_TALLY_LIVE_COMPANY_GUID"); + let from = live_env("BRIDGE_PRESENCE_LIVE_FROM"); + let to = live_env("BRIDGE_PRESENCE_LIVE_TO"); + let directory = tempfile::tempdir().expect("directory"); + let server = Server::new(Settings { + endpoint: TallyEndpointConfig { + host: "127.0.0.1".into(), + port, + }, + data_dir: directory.path().to_path_buf(), + max_rows: 500, + max_bytes: 4_000_000, + redaction: Redaction::None, + import_enabled: false, + // Read-only, and stated in the settings rather than only in a comment. + writes_enabled: false, + }); + + let read = server + .call_tool( + "vouchers", + json!({"company_guid": guid, "from": from, "to": to}), + ) + .await; + assert_eq!(read["isError"], false, "the window read failed"); + let rows = read["structuredContent"]["result"]["items"] + .as_array() + .expect("items") + .clone(); + let posted = rows + .iter() + .filter(|row| { + row["cancelled"] != json!(true) + && row["optional"] != json!(true) + && row["voucher_number"].is_string() + && row["party"].is_string() + && row["amounts"].as_array().is_some_and(|rows| rows.len() > 1) + }) + .collect::>(); + let needed = REPLAY_PRESENT + REPLAY_DIFFERING; + assert!( + posted.len() >= needed, + "the window holds {} usable vouchers and the replay needs {needed}; widen the dates", + posted.len() + ); + + // Paise, so the shortfall is exact. The read carries `bill_allocations` + // and `is_deemed_positive` that the proposal schema does not declare, so + // each entry is projected down to what a source document actually offers. + let paise = |amount: &str| -> i64 { + let (sign, digits) = match amount.strip_prefix('-') { + Some(rest) => (-1, rest), + None => (1, amount), + }; + let (whole, fraction) = digits.split_once('.').unwrap_or((digits, "0")); + let fraction = format!("{fraction:0<2}"); + sign * (whole.parse::().expect("whole") * 100 + + fraction[..2].parse::().expect("fraction")) + }; + let rupees = |value: i64| { + format!( + "{}{}.{:02}", + if value < 0 { "-" } else { "" }, + value.abs() / 100, + value.abs() % 100 + ) + }; + + let proposal_from = |row: &Value, short_by: i64| { + let mut shortfall = short_by; + let entries = row["amounts"] + .as_array() + .expect("amounts") + .iter() + .map(|entry| { + let value = paise(entry["amount"].as_str().expect("amount")); + // Magnitude is the sum of the non-negative entries, so the + // shortfall has to come off that side to be a difference at + // all. This is the engagement's shortfall, applied to the one + // line that carries the invoice value. + let adjusted = if shortfall > 0 && value > 0 { + shortfall = 0; + value - short_by + } else { + value + }; + json!({"ledger": entry["ledger"], "amount": rupees(adjusted)}) + }) + .collect::>(); + json!({ + "date": row["date"], + "voucher_type": row["voucher_type"], + "voucher_number": row["voucher_number"], + "party": row["party"], + "entries": entries, + }) + }; + + let mut proposals = posted + .iter() + .take(REPLAY_PRESENT) + .map(|row| proposal_from(row, 0)) + .collect::>(); + // The engagement's short-posted invoice was short in the *book*. This + // harness may not write, so the shortfall is introduced on the proposal + // side instead. The difference the report must find is the same one; only + // which side is missing the GST head is reversed. + proposals.push(proposal_from(posted[REPLAY_PRESENT], SHORT_BY_PAISE)); + // A new customer's invoice, which the engagement also had. It must differ + // from every book row in *party and amount*, not just in number: in a + // one-day window every row shares the date, so a known party alone would + // resemble something on date-and-party and withhold `absent` -- correctly, + // and that is a property of the window rather than of the proposal. + for index in 0..REPLAY_ABSENT { + let mut invented = proposal_from(posted[0], (index as i64 + 1) * 7_777); + invented["voucher_number"] = json!(format!("BRIDGE-REPLAY-ABSENT-{index:02}")); + invented["party"] = json!(REPLAY_UNKNOWN_PARTY); + invented["entries"][0]["ledger"] = json!(REPLAY_UNKNOWN_PARTY); + proposals.push(invented); + } + + let mut types = posted + .iter() + .take(needed) + .filter_map(|row| row["voucher_type"].as_str()) + .collect::>(); + types.sort_unstable(); + types.dedup(); + let numbering = types + .iter() + .map(|kind| json!({"voucher_type": kind, "numbering_method": "manual"})) + .collect::>(); + + let response = server + .call_tool( + "voucher_presence", + json!({"company_guid": guid, "from": from, "to": to, + "numbering": numbering, "vouchers": proposals}), + ) + .await; + let result = &response["structuredContent"]["result"]; + assert_eq!( + response["isError"], false, + "presence refused the replay: {}", + result["error"]["code"] + ); + + let items = result["items"].as_array().expect("items"); + // Counts, bases and reason codes only: never a name, number or amount. + let summarise = |entry: &Value| { + format!( + "{}/{}/{}", + entry["presence"].as_str().unwrap_or("?"), + entry["basis"].as_str().unwrap_or("-"), + entry["reason"].as_str().unwrap_or("-") + ) + }; + println!( + "replay totals: {} | verdicts: {:?}", + result["totals"], + items.iter().map(summarise).collect::>() + ); + println!("book observations: {}", result["book"]); + + for (index, entry) in items.iter().take(REPLAY_PRESENT).enumerate() { + assert_eq!( + entry["presence"], + "present", + "faithful copy {index} came back {}", + summarise(entry) + ); + assert!( + entry["differences"] + .as_array() + .is_some_and(|rows| rows.is_empty()), + "a faithful copy reported a difference at {index}" + ); + } + let differing = &items[REPLAY_PRESENT]; + assert_eq!( + differing["presence"], + "present", + "the short proposal came back {}", + summarise(differing) + ); + let fields = differing["differences"] + .as_array() + .expect("differences") + .iter() + .filter_map(|difference| difference["field"].as_str()) + .collect::>(); + assert!( + fields.contains(&"amount"), + "the short proposal reported {fields:?} rather than an amount difference" + ); + for (index, entry) in items.iter().skip(needed).enumerate() { + assert_eq!( + entry["presence"], + "absent", + "invented voucher {index} came back {}", + summarise(entry) + ); + } +} From 726ab639ed9cd0376c1cf03805292e41db8759dc Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 05:15:39 +0530 Subject: [PATCH 45/91] Make the live replay keep the promises its own header makes Four findings against the replay harness, all of them mine, and the first contradicts a claim written three lines above the code that breaks it. **It printed the whole `book` object.** The header says the harness "emits no book content -- counts, bases and reason codes only... never a party name, number or amount", and then `--nocapture` printed `duplicate_numbers` and `unbalanced_vouchers`, which carry real voucher numbers, voucher types and GUIDs, into a terminal or a CI log. On the window I ran it against those arrays were empty, so nothing escaped; the promise was still false, and a promise a reader has to check the code to trust is not a promise. Output is now filtered to the scalar fields by construction rather than by intention. **It declared every voucher type manually numbered.** The numbering method is an assertion about the *book* and the harness was making it up. Declaring an automatically numbered type `manual` lets Tally's own numbers produce `present` -- so the replay would have passed on verdicts the contract says are not identity, manufacturing the evidence it was offered as. The operator now names the manually numbered types and the replay refuses any other; verified by declaring the wrong one and watching it refuse. **The shortfall was not the shortfall.** Taking it off the first positive entry pushes that entry through zero whenever it is smaller than the shortfall, and `magnitude_of` sums only non-negative entries -- so the magnitude moved by the entry's whole value instead. The test asserted merely that *an* amount difference existed, so it would have passed while measuring something else entirely. It now takes the shortfall off the widest positive entry, only where that entry can absorb it, and asserts the **exact** difference. **ADR 0017 still said no verdict had been checked against a real book.** One has now: twenty of them. The ADR records the result and, at more length, its four limits -- read-only so the present ones are present by construction, the shortfall on the proposal side rather than the book's, the numbering method an operator assertion, and nothing at all about window completeness. Re-run against the live book after all four: sixteen present, four absent, exact shortfall confirmed. Pin set read off the branch at reseal: none added, none removed. Co-Authored-By: Claude Opus 5 --- docs/adr/0017-voucher-presence-authority.md | 26 ++++- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- src-tauri/src/agent_presence_tests.rs | 95 +++++++++++++++---- 4 files changed, 106 insertions(+), 21 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 60c4da79..485991ef 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -507,8 +507,30 @@ human-approved batch — this ADR does not move. requirement is satisfied upstream by the two reads that produce its input. Its own tests are fabricated from a placeholder alphabet: they establish the behaviour of the rules, and are not, and may not be presented as, evidence - about any Tally instance. **No verdict from this contract has yet been checked - against a real book.** + about any Tally instance. + +- **Twenty verdicts have been checked against a real book**, and the scope of + that check matters more than the fact of it. On a licensed TallyPrime 7.1 + Silver instance (`education_mode: false`), over an 82-voucher window of a + dense synthetic corpus: fifteen invoices the book already held returned + `Present` on `ManualVoucherNumber` with no differences, one shortened by a + fixed amount returned `Present` **with exactly that amount difference**, and + four invoices for a party the book had never seen returned `Absent`. + + Four limits travel with that result and none of them is incidental. + **It is read-only**: the proposals are built from the book's own rows, so the + present ones are present by construction — it shows the rules identify a + voucher they were shown, not one posted independently. **The shortfall is on + the wrong side**: the engagement's voucher was short in the *book*, and a + read-only harness can only shorten the proposal, so the difference detected + is the same one with the sides reversed. **The numbering method is the + operator's assertion**, not the book's: the replay refuses any voucher type + the operator has not declared manually numbered, because declaring an + automatically numbered type `manual` would manufacture the very `Present` + verdicts being offered as evidence. And it says **nothing about window + completeness** — it runs over a window whose completeness rests on the same + unproven cardinality described above, so it is evidence about the *rules* and + not about the read. ## Alternatives rejected diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 97393a8b..1d7edf0a 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": "8b48f8f3bf66f52b41603ea85d7f25e4c2354b0fbdae31de2258e4e978666e97", + "compatibility_surface_sha256": "431588a40186b2337944e94704803639299e07fcb4a4eab62ac0f04aa84b91e4", "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 137af6e9..462cb040 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -343,7 +343,7 @@ }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "886914f5848fb0d27417f289816eec5ba773b11ae17ac351a000eb190b12b3be" + "sha256": "c9211faabe1094caad45ddb2b37d50d63876bbcfa5cf23d597f0e79626400437" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -858,5 +858,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "8b48f8f3bf66f52b41603ea85d7f25e4c2354b0fbdae31de2258e4e978666e97" + "manifest_sha256": "431588a40186b2337944e94704803639299e07fcb4a4eab62ac0f04aa84b91e4" } \ No newline at end of file diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index 80450dc2..073060a1 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -935,6 +935,9 @@ fn a_maximal_book_degrades_to_its_counts_rather_than_costing_the_report() { // engagement and the assertion says so. // * It **emits no book content** — counts, bases and reason codes only. A // failure prints what went wrong, never a party name, number or amount. +// The observation object is filtered to its scalar fields to keep that +// true: its listed groups carry real voucher numbers, types and GUIDs, +// and `--nocapture` output reaches terminals and CI logs. // --------------------------------------------------------------------------- /// How many faithful copies to propose, how many to perturb, how many to invent. @@ -1033,20 +1036,35 @@ async fn replay_the_twenty_invoice_engagement() { ) }; + // Magnitude is the sum of the non-negative entries, so a shortfall must + // come off that side to be a difference at all — and it must not push the + // entry through zero, or the entry leaves the sum entirely and the + // magnitude moves by its whole value rather than by the shortfall. The + // test would still see *a* difference and still pass, measuring something + // other than what it says it measures. So it is taken off the largest + // positive entry, and only where that entry can absorb it. + let widest_positive = |row: &Value| -> Option { + row["amounts"] + .as_array() + .expect("amounts") + .iter() + .enumerate() + .map(|(at, entry)| (at, paise(entry["amount"].as_str().expect("amount")))) + .filter(|(_, value)| *value > SHORT_BY_PAISE) + .max_by_key(|(_, value)| *value) + .map(|(at, _)| at) + }; + let proposal_from = |row: &Value, short_by: i64| { - let mut shortfall = short_by; + let target = (short_by > 0).then(|| widest_positive(row).expect("an entry to shorten")); let entries = row["amounts"] .as_array() .expect("amounts") .iter() - .map(|entry| { + .enumerate() + .map(|(at, entry)| { let value = paise(entry["amount"].as_str().expect("amount")); - // Magnitude is the sum of the non-negative entries, so the - // shortfall has to come off that side to be a difference at - // all. This is the engagement's shortfall, applied to the one - // line that carries the invoice value. - let adjusted = if shortfall > 0 && value > 0 { - shortfall = 0; + let adjusted = if target == Some(at) { value - short_by } else { value @@ -1072,7 +1090,14 @@ async fn replay_the_twenty_invoice_engagement() { // harness may not write, so the shortfall is introduced on the proposal // side instead. The difference the report must find is the same one; only // which side is missing the GST head is reversed. - proposals.push(proposal_from(posted[REPLAY_PRESENT], SHORT_BY_PAISE)); + // The row to shorten has to be able to absorb the shortfall. Picking + // blindly is how the perturbation silently becomes a different one. + let shortened = posted + .iter() + .skip(REPLAY_PRESENT) + .find(|row| widest_positive(row).is_some()) + .expect("a voucher whose invoice line exceeds the shortfall"); + proposals.push(proposal_from(shortened, SHORT_BY_PAISE)); // A new customer's invoice, which the engagement also had. It must differ // from every book row in *party and amount*, not just in number: in a // one-day window every row shares the date, so a known party alone would @@ -1086,6 +1111,18 @@ async fn replay_the_twenty_invoice_engagement() { proposals.push(invented); } + // The numbering method is an *assertion about the book*, and the harness + // is not entitled to make it. Declaring an automatically numbered type + // `manual` would let Tally's own numbers produce `present` and the replay + // would pass on verdicts the contract says are not identity — evidence + // manufactured by the test rather than found in the book. So the operator + // names the manually numbered types and the replay refuses any other. + let declared = live_env("BRIDGE_PRESENCE_LIVE_MANUAL_TYPES"); + let declared = declared + .split(',') + .map(str::trim) + .filter(|kind| !kind.is_empty()) + .collect::>(); let mut types = posted .iter() .take(needed) @@ -1093,6 +1130,13 @@ async fn replay_the_twenty_invoice_engagement() { .collect::>(); types.sort_unstable(); types.dedup(); + for kind in &types { + assert!( + declared.contains(kind), + "a voucher type in this window was not declared manually numbered; \ + set BRIDGE_PRESENCE_LIVE_MANUAL_TYPES or narrow the window" + ); + } let numbering = types .iter() .map(|kind| json!({"voucher_type": kind, "numbering_method": "manual"})) @@ -1127,7 +1171,20 @@ async fn replay_the_twenty_invoice_engagement() { result["totals"], items.iter().map(summarise).collect::>() ); - println!("book observations: {}", result["book"]); + // Scalars only, by construction rather than by intention. `book` also + // carries `duplicate_numbers` and `unbalanced_vouchers`, and those hold + // real voucher numbers, voucher types and GUIDs -- printing the object + // whole would put customer accounting data into a terminal or a CI log, + // which is exactly what the header above promises this does not do. A + // promise a reader has to check the code to trust is not a promise. + let counts = result["book"] + .as_object() + .expect("book") + .iter() + .filter(|(_, value)| !value.is_array()) + .map(|(key, value)| format!("{key}={value}")) + .collect::>(); + println!("book observations: {}", counts.join(" ")); for (index, entry) in items.iter().take(REPLAY_PRESENT).enumerate() { assert_eq!( @@ -1150,15 +1207,21 @@ async fn replay_the_twenty_invoice_engagement() { "the short proposal came back {}", summarise(differing) ); - let fields = differing["differences"] + // The *exact* shortfall, not merely "some difference". Asserting only + // that a difference exists is what let the perturbation drift into + // something else while the test went on passing. + let amount = differing["differences"] .as_array() .expect("differences") .iter() - .filter_map(|difference| difference["field"].as_str()) - .collect::>(); - assert!( - fields.contains(&"amount"), - "the short proposal reported {fields:?} rather than an amount difference" + .find(|difference| difference["field"] == "amount") + .expect("an amount difference"); + let proposed = paise(amount["proposed"].as_str().expect("proposed")); + let observed = paise(amount["observed"].as_str().expect("observed")); + assert_eq!( + observed - proposed, + SHORT_BY_PAISE, + "the reported shortfall is not the one the proposal applied" ); for (index, entry) in items.iter().skip(needed).enumerate() { assert_eq!( From 0264f722a0920a2c324733e2f29f9c2a082e45c2 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:02:16 +0530 Subject: [PATCH 46/91] Declare numbering for the rows the replay actually proposes Two findings against the live replay harness. The documented invocation omitted `BRIDGE_PRESENCE_LIVE_MANUAL_TYPES`, which the previous commit made mandatory -- so the one command in this repository that produces a live verdict panicked for anyone who followed it. A reproduction recipe that does not reproduce is worse than none, because it looks like one. And the numbering declaration was derived from the first sixteen posted rows while the shortened proposal is whichever *later* row can absorb the perturbation. Where those differ in voucher type, the request carries no declaration for the proposal that actually differs and the whole run is refused with `presence_numbering_method_undeclared` -- an otherwise suitable window unable to complete the replay for a reason having nothing to do with the book. The declaration now comes from the rows that become proposals. The guard requiring every such type to be operator-declared is untouched, because that one is deliberate. Co-Authored-By: Claude Opus 5 --- src-tauri/src/agent_presence_tests.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index 073060a1..5b7eb7a2 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -960,6 +960,7 @@ fn live_env(key: &str) -> String { /// BRIDGE_TALLY_LIVE_PORT=9001 \ /// BRIDGE_TALLY_LIVE_COMPANY_GUID= \ /// BRIDGE_PRESENCE_LIVE_FROM=YYYYMMDD BRIDGE_PRESENCE_LIVE_TO=YYYYMMDD \ +/// BRIDGE_PRESENCE_LIVE_MANUAL_TYPES= \ /// cargo test -p bridge --lib replay_the_twenty_invoice_engagement -- --ignored --nocapture /// ``` #[tokio::test] @@ -1123,9 +1124,16 @@ async fn replay_the_twenty_invoice_engagement() { .map(str::trim) .filter(|kind| !kind.is_empty()) .collect::>(); + // `shortened` is whichever row past the faithful slice could absorb the + // perturbation, not necessarily `posted[REPLAY_PRESENT]` -- so the types + // in the declaration have to be read off the rows that actually became + // proposals (the faithful fifteen plus `shortened`) rather than off the + // first `needed` rows of `posted`, or a shortfall landing on a later type + // leaves that type's proposal without a numbering declaration at all. let mut types = posted .iter() - .take(needed) + .take(REPLAY_PRESENT) + .chain(std::iter::once(shortened)) .filter_map(|row| row["voucher_type"].as_str()) .collect::>(); types.sort_unstable(); From b8ea717902a4d9ed7fe09e8dc677a60c05deb671 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:11:12 +0530 Subject: [PATCH 47/91] Compare a voucher number on what was measured, not on a borrowed key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A voucher number used the master-name comparison key, which folds case and unifies dash and quote variants. That fold is not arbitrary -- §3.3b measured Tally's own master-name matching and the key follows it -- but nothing has measured it for *numbers*. The ADR cited the master measurement to justify a rule about numbers, which is how an assumption acquires a citation. It also folded in the silent direction. Folding produces more matches, a wrong number match settles `Present`, and `Present` tells a caller the invoice is already filed. Two distinct invoices numbered `aa-0118` and `AA-0118` would each have suppressed the other, and the proposal need not even be in the window for it to happen: it is enough that some other voucher's number folds onto the proposal's and is unique under the fold. Numbers now compare on NFC and collapsed whitespace alone. Both are transport artefacts -- the same number typed two ways is the same number, and Tally pads its own fields, which the live read shows it doing. Case and punctuation are content until something measures otherwise, and treating them so fails toward the noisy direction: an unmatched punctuation variant reads as absent, costing a duplicate a person can see rather than an invoice nobody does. Voucher *types* keep the master key, because a voucher type is a Tally master and §3.3b measured that case. `a_voucher_number_is_compared_on_the_same_key_as_a_master_name` asserted the old rule and now asserts the new one under a name that says which it is. It is the reason this was a contract change rather than an edit: a test was standing on the behaviour, which is what tests are for. Pin set read off the branch at reseal: none added, none removed. Co-Authored-By: Claude Opus 5 --- docs/adr/0017-voucher-presence-authority.md | 26 ++++++-- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 6 +- .../bridge-tally-core/src/book_presence.rs | 32 ++++++++- .../src/book_presence_tests.rs | 65 ++++++++++++++++++- 5 files changed, 118 insertions(+), 13 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 485991ef..0df3f08f 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -247,11 +247,27 @@ leaves open: would make the tool less useful — which is the posture in §7 argued backwards, and review caught it. -Number comparison uses the same NFC / dash-and-quote / case / whitespace -comparison key as master binding, so a long alphanumeric invoice number and its -differently punctuated twin agree. - -Voucher types are compared on that key too, and a manual number decides only +Number comparison uses a **narrower** key than master binding, and the +difference is the point. The master key folds case and unifies dash and quote +variants because §3.3b measured Tally doing exactly that to master *names*; +nothing has measured it for voucher numbers. Borrowing the conclusion without +the measurement is how an assumption acquires a citation, and this one fails in +the silent direction — folding produces *more* matches, a wrong number match is +a `Present`, and a `Present` tells a caller the invoice is already filed. Two +distinct invoices numbered `aa-0118` and `AA-0118` would each have suppressed +the other. + +So a number is compared on NFC and collapsed whitespace only. Both are +transport artefacts: the same number typed two ways is the same number, and +Tally pads its own fields. Case and punctuation are **content** until something +measures otherwise, and treating them so fails toward the noisy direction — +an unmatched punctuation variant reads as absent, which costs a duplicate a +person can see rather than an invoice nobody does. + +Voucher *types* keep the master key, because a voucher type is a Tally master +and §3.3b measured that case. + +A manual number decides only **within an observed voucher type** — numbers are a per-type series, so a match across types is a coincidence, not a series position. If a proposal's voucher type is **not observed anywhere in the window**, type discriminates nothing, so diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 1d7edf0a..42cac234 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": "431588a40186b2337944e94704803639299e07fcb4a4eab62ac0f04aa84b91e4", + "compatibility_surface_sha256": "37fcf5d43897d812d78f08c3a45f624933ff0e509119414f51136fdfb71bfc5c", "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 462cb040..9d5b38be 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "b2f69a1c183aee826054963f0bd0cd784e261bc9a2174e01c1bae0965e43dccd" + "sha256": "2a1d83997ff65fd9d119f2e838963bd8898f064c66851b26fb911dd190c3359d" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -343,7 +343,7 @@ }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "c9211faabe1094caad45ddb2b37d50d63876bbcfa5cf23d597f0e79626400437" + "sha256": "f2c2012d1ddb0eb9cc2e69769c55d477470e0d54a9aa93afcb478319b77132d2" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -858,5 +858,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "431588a40186b2337944e94704803639299e07fcb4a4eab62ac0f04aa84b91e4" + "manifest_sha256": "37fcf5d43897d812d78f08c3a45f624933ff0e509119414f51136fdfb71bfc5c" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 1b588ae2..f93a5afd 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -20,6 +20,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; +use unicode_normalization::UnicodeNormalization; use crate::exact_arithmetic::ExactDecimalAccumulator; use crate::master_binding::{ @@ -277,7 +278,7 @@ impl BookVoucher { ledger_keys.insert(comparison_key(party)); } let type_key = comparison_key(&voucher_type); - let number_key = voucher_number.as_deref().map(comparison_key); + let number_key = voucher_number.as_deref().map(number_key_of); Ok(Self { key, date, @@ -346,7 +347,7 @@ impl ProposedVoucher { let party = input.party.map(validated_text).transpose()?; let (magnitude, _, _) = magnitude_of(input.entries)?; let type_key = comparison_key(&voucher_type); - let number_key = voucher_number.as_deref().map(comparison_key); + let number_key = voucher_number.as_deref().map(number_key_of); Ok(Self { position: input.position, date, @@ -1644,6 +1645,33 @@ fn label(value: &str) -> String { bounded } +/// The key a *voucher number* is compared on, which is deliberately narrower +/// than the one master names use. +/// +/// `comparison_key` folds case and unifies dash and quote variants, and that +/// fold is not arbitrary: §3.3b measured Tally's own master-name matching and +/// the key follows it. **No such measurement exists for voucher numbers.** +/// Applying the name fold to them was an assumption wearing a measurement's +/// clothes, and it fails in the silent direction: folding produces *more* +/// matches, a wrong match on a number is a `Present`, and a `Present` tells a +/// caller an invoice is already filed. Two distinct invoices numbered `a-1` +/// and `A-1` would have suppressed one another. +/// +/// What remains is encoding, not semantics. NFC because the same number typed +/// two ways is the same number, and whitespace collapse because Tally pads its +/// own fields -- both are artefacts of transport. Case and punctuation are +/// content until something measures otherwise, and this narrows toward the +/// noisy failure: an unmatched punctuation variant reads as absent, which +/// costs a duplicate a person can see. +fn number_key_of(value: &str) -> String { + value + .nfc() + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + fn keep_strongest( found: &mut BTreeMap, position: usize, diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 7ed15668..75a9c3c6 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -416,8 +416,20 @@ fn a_voucher_number_decides_nothing_under_unknown_numbering() { assert_eq!(reason(only(&report)), UndecidedReason::NumberNotDecisive); } +/// This asserted the opposite until a review asked what measured it. +/// +/// A voucher number used the master-name key, so `aa-0118` matched `AA-0118` +/// and settled `Present`. The fold that key applies is not arbitrary -- §3.3b +/// measured Tally's own master-name matching and the key follows it -- but +/// nothing measured it for *numbers*, and borrowing the conclusion without the +/// measurement is how an assumption acquires a citation. +/// +/// It also fails in the wrong direction. Folding produces more matches, a +/// wrong number match is a `Present`, and a `Present` tells a caller the +/// invoice is already filed. Two distinct invoices numbered `aa-0118` and +/// `AA-0118` would each have suppressed the other. #[test] -fn a_voucher_number_is_compared_on_the_same_key_as_a_master_name() { +fn a_voucher_number_is_not_folded_the_way_a_master_name_is() { let window = window(&[BookRow::new("book-1", "20260812", "aa-0118")]); let proposals = [ProposalRow::new(0, "20260812", "AA-0118").build()]; let report = run( @@ -426,7 +438,11 @@ fn a_voucher_number_is_compared_on_the_same_key_as_a_master_name() { &numbering(NumberingMethod::Manual), &proposals, ); - assert_eq!(only(&report).present_book_key(), Some("book-1")); + assert_eq!( + only(&report).present_book_key(), + None, + "case is content in a number until a measurement says otherwise" + ); } #[test] @@ -1926,6 +1942,51 @@ fn a_large_number_collision_reports_its_true_size_without_listing_it() { assert_eq!(report.observations().duplicate_number_group_count, 1); } +/// A voucher number is content, not a name, and the two are folded +/// differently on purpose. +/// +/// `comparison_key` lowercases and unifies dash and quote variants because +/// §3.3b measured Tally doing that to master *names*. Nothing measured it for +/// numbers, and the fold fails in the silent direction: it produces more +/// matches, a wrong number match is a `Present`, and `Present` tells a caller +/// an invoice is already filed. Two distinct invoices differing only in case +/// would have suppressed one another. +#[test] +fn two_numbers_differing_only_in_case_are_two_numbers() { + let cased = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "aa0118") + .party("Bravo Industries") + .rows(vec![ + ["Bravo Industries", "-4200.00"], + ["Sales Account", "4200.00"], + ]) + .build()]; + let report = run( + &cased, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!( + only(&report).present_book_key(), + None, + "a case variant is a different number until something measures otherwise" + ); + + // Encoding still folds: Tally pads its own fields, and the same number + // typed two ways is the same number. + let padded_rows = [BookRow::new("book-1", "20260812", "AA 0118")]; + let padded = window(&padded_rows); + let spaced = [ProposalRow::new(0, "20260812", "AA 0118").build()]; + let report = run( + &padded, + &catalog(), + &numbering(NumberingMethod::Manual), + &spaced, + ); + assert_eq!(only(&report).present_book_key(), Some("book-1")); +} + /// Under a `Manual` declaration the number is the one key that can decide, so /// a proposal supplying none has offered nothing decisive — an absence would /// rest on date, party and amount, which this contract does not let decide. From 9cc3b0a9ba551a0c19ce1d7700dda5f829bbb078 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 13:24:00 +0530 Subject: [PATCH 48/91] Record what the widened re-read cost and why it does not close the gap A corroborating read that re-checks a nonempty window against the same range widened a day was built, measured live, and is not being shipped. What survives is the reasoning, written into the section it concerns so the next attempt starts past it rather than at it. It detects something real: a wider read is denser, so it truncates at least as hard, and a row the narrow read has and the wider one lacks proves the narrow read short. Size-driven shortness is the case that section is about. It cannot establish completeness, and the refutation was already two sentences above it in this ADR -- a deterministic short answer agrees with itself. If the added boundary days are empty, both reads drop the same suffix, agree, and agreement becomes indistinguishable from correctness. The mechanism converts some false `Complete`s into `Partial`; it licenses none. And it is not cheap. On a licensed 7.1 Silver book a one-day window reads 895,888 bytes against 1,744,152 for its widened corroboration -- 1.95x, one call going from ~896 KB to ~2.6 MB, roughly halving the widest window the tool can serve before corroboration alone fails it. Widening also moves the requested boundary, and on Education-mode Tally an accepted boundary can widen into an unsupported one that Tally silently reads as the whole book. A detector with a real cost, a live correctness hazard, and no ability to close the finding it was built for. The source-side control total remains the only thing that would, and it is still a separate read contract needing its own evidence. Co-Authored-By: Claude Opus 5 --- docs/adr/0017-voucher-presence-authority.md | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 0df3f08f..553512b0 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -503,6 +503,32 @@ human-approved batch — this ADR does not move. read contract with its own live evidence. Until then, prefer several narrow windows to one dense one, and read `Absent` as scoped to a window that was read narrow enough to trust. + +- **A widened re-read was built, measured and rejected**, and the reasoning is + recorded here so the next attempt starts past it rather than at it. The idea + is to re-read the same range a day wider and compare the two reads on Tally's + own `GUID`s for the rows inside the original window: a row the wider read saw + and the narrow one did not proves the narrow read short. + + It does detect something real. A wider read is denser, so it truncates at + least as hard, and size-driven shortness is the case this section is about. + But it **cannot establish completeness**, for the reason stated two sentences + up in this same paragraph: a deterministic short answer agrees with itself. + If the added boundary days are empty, both reads drop the same suffix and + agree, and agreement is then indistinguishable from correctness. It converts + some false `Complete`s into `Partial`; it licenses none. + + The price is not small. Measured against a licensed 7.1 Silver book, a + one-day window reads 895,888 bytes and its widened corroboration reads + 1,744,152 — **1.95x**, taking one call from ~896 KB to ~2.6 MB, which roughly + halves the widest window the tool can serve before corroboration alone fails + it. Widening also moves the requested boundary, and on Education-mode Tally + an accepted boundary can widen into an unsupported one that Tally silently + reinterprets as the whole book. + + So: a detector with a real cost, a live correctness hazard, and no ability to + close the finding it was built for. The source-side control total above + remains the only thing that would. - **The identifier rule that binds a party across spellings is bimodal, not general.** Measured across three catalogs: zero of 470 names across sixteen loaded synthetic companies, zero of 105 on one real book, and **91 of 214 — From 0978559c8668e6a930c91672497722141eb714e4 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 17:28:57 +0530 Subject: [PATCH 49/91] fix: make voucher presence observations deterministic --- docs/adr/0017-voucher-presence-authority.md | 6 ++--- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 6 ++--- scripts/check-mcpb-bundle.py | 2 +- .../bridge-tally-core/src/book_presence.rs | 5 ++-- .../src/book_presence_tests.rs | 26 +++++++++++++++++++ src-tauri/src/agent_catalog.rs | 2 +- src-tauri/src/agent_presence_tests.rs | 3 +++ 8 files changed, 41 insertions(+), 11 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 879bffb9..8956038e 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -543,9 +543,9 @@ human-approved batch — this ADR does not move. `PossiblyPresent` into an `Absent` — a visible, deletable duplicate — and can never turn an `Absent` into a `Present`, which is the silent direction. That is the asymmetry of §7 holding under a rule that is not yet proven. -- Presence is pure computation over already-observed data, so P1's live-evidence - requirement is satisfied upstream by the two reads that produce its input. Its - own tests are fabricated from a placeholder alphabet: they establish the +- Presence is pure computation over already-observed data, but P1's live-evidence + requirement remains unmet: the two reads do not establish source completeness. + Its own tests are fabricated from a placeholder alphabet: they establish the behaviour of the rules, and are not, and may not be presented as, evidence about any Tally instance. diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 848e4664..d9dfbafa 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": "b23b562abe7d28a2ce7dc89766f8175f9cf2efede10283dbfccd8335817fd855", + "compatibility_surface_sha256": "be3a9a7317b4deacbd7fdc00fe28788ab9cbfbb79504c050beb00c2f50a8b236", "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 444ea186..b9250f8a 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "cb61a4ceaa9b4206b1ba2fa25738974412dcaff1b2e580c7bb42e9441f2053ce" + "sha256": "342c90d166ccffcffd4f70f91bdc95ccb6ac92f07521822600373fe867152c69" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -347,7 +347,7 @@ }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "75d5104465458215dc8d3d8587e6692685e9b418d4f9e83a706e8294a3f98178" + "sha256": "0b8e6074031f9a78632b250439e3646db51433955e40f20fdc0dbef0ccac1031" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "b23b562abe7d28a2ce7dc89766f8175f9cf2efede10283dbfccd8335817fd855" + "manifest_sha256": "be3a9a7317b4deacbd7fdc00fe28788ab9cbfbb79504c050beb00c2f50a8b236" } \ No newline at end of file diff --git a/scripts/check-mcpb-bundle.py b/scripts/check-mcpb-bundle.py index d7934804..bc0c235a 100644 --- a/scripts/check-mcpb-bundle.py +++ b/scripts/check-mcpb-bundle.py @@ -16,7 +16,7 @@ RESOURCES = ("LICENSE", "NOTICE", "THIRD_PARTY_LICENSES.txt", "THIRD_PARTY_LICENSES_RUST.txt") DEFAULT_TOOLS = { "tally_status", "list_companies", "voucher_schema", "validate_masters", "outstandings", - "ledger_masters", "ledger_movement", "trial_balance", "vouchers", "read_evidence", "egress_log", "verify_import", + "ledger_masters", "ledger_movement", "trial_balance", "vouchers", "voucher_presence", "read_evidence", "egress_log", "verify_import", } MAX_BUNDLE_BYTES = 128 * 1024 * 1024 MAX_OUTPUT_BYTES = 512 * 1024 diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 91a9a85b..99ea0d1a 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -1578,7 +1578,7 @@ fn observe( if duplicate_numbers.len() >= MAX_DUPLICATE_NUMBER_GROUPS { continue; } - let mut ordered = positions.iter().copied().collect::>(); + let mut ordered = positions.to_vec(); ordered.sort_by(|left, right| { window.vouchers[*left] .key() @@ -1601,11 +1601,12 @@ fn observe( }); } - let unbalanced: Vec<&BookVoucher> = window + let mut unbalanced: Vec<&BookVoucher> = window .vouchers .iter() .filter(|voucher| !voucher.balanced()) .collect(); + unbalanced.sort_by(|left, right| left.key().cmp(right.key())); let unmatched_book_vouchers = window .vouchers diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 715b3a29..63795896 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -975,6 +975,32 @@ fn an_unbalanced_book_voucher_is_reported_and_still_matched() { assert_eq!(only(&report).present_book_key(), Some("book-1")); } +#[test] +fn unbalanced_observation_listing_is_sorted_before_its_cap() { + let rows = [ + "book-z", "book-a", "book-b", "book-c", "book-d", "book-e", "book-f", "book-g", "book-h", + "book-i", "book-j", "book-k", "book-l", "book-m", "book-n", "book-o", "book-p", "book-q", + "book-r", "book-s", "book-t", "book-u", "book-v", "book-w", "book-x", "book-y", + ] + .map(|key| BookRow::new(key, "20260812", "AA0118").rows(vec![["Alpha Traders", "-1.00"]])); + let window = window(&rows); + let proposals = [ProposalRow::new(0, "20260812", "AA0999").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + + assert_eq!(report.observations().unbalanced_voucher_count, 26); + assert_eq!( + report.observations().unbalanced_vouchers, + (b'a'..=b'y') + .map(|suffix| format!("book-{}", char::from(suffix))) + .collect::>() + ); +} + // --- book observations -------------------------------------------------- #[test] diff --git a/src-tauri/src/agent_catalog.rs b/src-tauri/src/agent_catalog.rs index 1b79607c..99a79376 100644 --- a/src-tauri/src/agent_catalog.rs +++ b/src-tauri/src/agent_catalog.rs @@ -271,7 +271,7 @@ pub(super) fn registered_tool_definitions(import_enabled: bool, writes_enabled: json!({"type":"object","additionalProperties":false,"required":["company_guid","from","to"],"properties":{"company_guid":{"type":"string","minLength":1},"from":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"},"to":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"},"voucher_type":{"type":"string","maxLength":agent_import::MAX_MASTER_NAME_CHARS},"ledger":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"},"offset":{"type":"integer","minimum":0,"default":0},"limit":{"type":"integer","minimum":1,"default":500}}}), ), "voucher_presence" => ( - "Answer which of 1\u{2013}500 proposed vouchers are already in the book, over one literal window. Tally dedupes on one key only: re-sending a voucher under the same VOUCHERNUMBER creates a second one, while a client-supplied REMOTEID upserts instead. A voucher keyed by hand carries no client REMOTEID, so it is the one at duplication risk. `presence` is present, possibly_present or absent, and only `present` names a book voucher. Only identity decides: a shared REMOTEID, or a voucher number on a voucher type you declare `manual` \u{2014} unique on both sides, within an observed voucher type, and never onto a cancelled or optional voucher. Date, party and amount only ever produce candidates, with the rule that surfaced each and no ranking or score. Every voucher type a proposal names needs a declared numbering method; under `automatic` Tally discards the supplied number, so nothing can be decided from it. `absent` means absent from this window, so cover the dates the book could hold. Reads the full window before comparing; dense windows can fail source limits. Party names bind through the same rules as validate_masters. A reported difference on a `present` voucher is a finding for a person, not a work item: correcting a voucher by Alter or Cancel silently creates a duplicate instead (\u{00a7}9.7), and no Bridge path can correct a voucher it did not write. This never dispatches import XML to Tally.", + "Answer which of 1\u{2013}500 proposed vouchers are already in the book, over one literal window. `presence` is present, possibly_present or absent, and only `present` names a book voucher. This surface can decide identity only from a voucher number on a voucher type you declare `manual` \u{2014} unique on both sides, within an observed voucher type, and never onto a cancelled or optional voucher. It neither accepts nor reads client remote identifiers. Date, party and amount only ever produce candidates, with the rule that surfaced each and no ranking or score. Every voucher type a proposal names needs a declared numbering method; under `automatic` Tally discards the supplied number, so nothing can be decided from it. `absent` means absent from this window, so cover the dates the book could hold. Reads the full window before comparing; dense windows can fail source limits. Party names bind through the same rules as validate_masters. A reported difference on a `present` voucher is a finding for a person, not a work item: correcting a voucher by Alter or Cancel silently creates a duplicate instead (\u{00a7}9.7), and no Bridge path can correct a voucher it did not write. This never dispatches import XML to Tally.", json!({"type":"object","additionalProperties":false,"required":["company_guid","from","to","numbering","vouchers"],"properties":{ "company_guid":{"type":"string","minLength":1}, "offset":{"type":"integer","minimum":0,"default":0}, diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index a4296cb2..a0c9df76 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -153,6 +153,9 @@ fn the_published_schema_names_the_three_numbering_methods_and_its_bounds() { ); // The tool reads; it must not be annotated as a write. assert!(tool.get("annotations").is_none()); + let description = tool["description"].as_str().expect("tool description"); + assert!(description.contains("manual")); + assert!(!description.contains("REMOTEID")); } /// `remote_id` is no longer an accepted input: the shipped read cannot fetch From 89da340237afb0e5706820d2c2484b0f5774456b Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 17:31:41 +0530 Subject: [PATCH 50/91] docs: disclose unqualified presence windows --- src-tauri/src/agent_catalog.rs | 2 +- src-tauri/src/agent_presence_tests.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/agent_catalog.rs b/src-tauri/src/agent_catalog.rs index 99a79376..e039686c 100644 --- a/src-tauri/src/agent_catalog.rs +++ b/src-tauri/src/agent_catalog.rs @@ -271,7 +271,7 @@ pub(super) fn registered_tool_definitions(import_enabled: bool, writes_enabled: json!({"type":"object","additionalProperties":false,"required":["company_guid","from","to"],"properties":{"company_guid":{"type":"string","minLength":1},"from":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"},"to":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"},"voucher_type":{"type":"string","maxLength":agent_import::MAX_MASTER_NAME_CHARS},"ledger":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"},"offset":{"type":"integer","minimum":0,"default":0},"limit":{"type":"integer","minimum":1,"default":500}}}), ), "voucher_presence" => ( - "Answer which of 1\u{2013}500 proposed vouchers are already in the book, over one literal window. `presence` is present, possibly_present or absent, and only `present` names a book voucher. This surface can decide identity only from a voucher number on a voucher type you declare `manual` \u{2014} unique on both sides, within an observed voucher type, and never onto a cancelled or optional voucher. It neither accepts nor reads client remote identifiers. Date, party and amount only ever produce candidates, with the rule that surfaced each and no ranking or score. Every voucher type a proposal names needs a declared numbering method; under `automatic` Tally discards the supplied number, so nothing can be decided from it. `absent` means absent from this window, so cover the dates the book could hold. Reads the full window before comparing; dense windows can fail source limits. Party names bind through the same rules as validate_masters. A reported difference on a `present` voucher is a finding for a person, not a work item: correcting a voucher by Alter or Cancel silently creates a duplicate instead (\u{00a7}9.7), and no Bridge path can correct a voucher it did not write. This never dispatches import XML to Tally.", + "For a qualified complete window, answer which of 1\u{2013}500 proposed vouchers are already in the book. At present, the adapter has no source-completeness evidence for a nonempty window, so it refuses one as `presence_window_incomplete` and emits no operational presence verdict. `presence` is present, possibly_present or absent, and only `present` names a book voucher. The conditional decision basis can use a voucher number on a voucher type you declare `manual` \u{2014} unique on both sides, within an observed voucher type, and never onto a cancelled or optional voucher. It neither accepts nor reads client remote identifiers. Date, party and amount only ever produce candidates, with the rule that surfaced each and no ranking or score. Every voucher type a proposal names needs a declared numbering method; under `automatic` Tally discards the supplied number, so nothing can be decided from it. `absent` means absent from this window, so cover the dates the book could hold. Reads the full window before comparing; dense windows can fail source limits. Party names bind through the same rules as validate_masters. A reported difference on a `present` voucher is a finding for a person, not a work item: correcting a voucher by Alter or Cancel silently creates a duplicate instead (\u{00a7}9.7), and no Bridge path can correct a voucher it did not write. This never dispatches import XML to Tally.", json!({"type":"object","additionalProperties":false,"required":["company_guid","from","to","numbering","vouchers"],"properties":{ "company_guid":{"type":"string","minLength":1}, "offset":{"type":"integer","minimum":0,"default":0}, diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index a0c9df76..9d09168d 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -155,6 +155,7 @@ fn the_published_schema_names_the_three_numbering_methods_and_its_bounds() { assert!(tool.get("annotations").is_none()); let description = tool["description"].as_str().expect("tool description"); assert!(description.contains("manual")); + assert!(description.contains("presence_window_incomplete")); assert!(!description.contains("REMOTEID")); } From 91bb7885b1ab7fa390e38a0c606d12ce8cabe8a1 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 18:42:19 +0530 Subject: [PATCH 51/91] chore: reseal voucher presence coverage --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index d9dfbafa..4c8c0c00 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": "be3a9a7317b4deacbd7fdc00fe28788ab9cbfbb79504c050beb00c2f50a8b236", + "compatibility_surface_sha256": "7dfb22a7f42b2219622ac4685e35ca011c7b4490e31c500c75714fdfd4ed0340", "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 b9250f8a..23effd67 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -347,7 +347,7 @@ }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "0b8e6074031f9a78632b250439e3646db51433955e40f20fdc0dbef0ccac1031" + "sha256": "c0bd4ae4b46fbcfed99e3c3b3ddc6e5b4ce3acd887448549e46ea45bfaf186ce" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "be3a9a7317b4deacbd7fdc00fe28788ab9cbfbb79504c050beb00c2f50a8b236" + "manifest_sha256": "7dfb22a7f42b2219622ac4685e35ca011c7b4490e31c500c75714fdfd4ed0340" } \ No newline at end of file From 6d5fba1ed782cb2db2ff0800c3fa0f671e0d79e9 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 19:45:33 +0530 Subject: [PATCH 52/91] rectify voucher presence identity and bounds --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 8 +- .../bridge-tally-core/src/book_presence.rs | 65 +++++-- .../src/book_presence_tests.rs | 162 +++++++++++++++++- src-tauri/src/agent_presence.rs | 52 +++--- src-tauri/src/agent_presence_tests.rs | 10 +- 6 files changed, 249 insertions(+), 50 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 731b9fd5..103c14c7 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": "88ffd4906879ee5ab9eea104d0dfb893496872aff119b47061e7160b62b376fe", + "compatibility_surface_sha256": "f3fdfecf1eb7c138f56e9f5e531bfed23b8d83e77dc8ce8194112ab6f9a0d374", "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 b733d4a9..72947eff 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "342c90d166ccffcffd4f70f91bdc95ccb6ac92f07521822600373fe867152c69" + "sha256": "6e0094161a087efd2f919acfcff08d43f90558886e8b64449828c21d6b2f70fa" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -343,11 +343,11 @@ }, { "path": "src-tauri/src/agent_presence.rs", - "sha256": "070ccb6f746abe320f42842610b90ef968c5c45d86a758b605b7d8596fb3504e" + "sha256": "deb87916a9ea8135170c28555b4bbf5e1b7649c1a0c47e1d0ab35951161ca37e" }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "c0bd4ae4b46fbcfed99e3c3b3ddc6e5b4ce3acd887448549e46ea45bfaf186ce" + "sha256": "0783087fd025fcffe9034bc5c851d41c7941b318539da327a84e8b549a3135be" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "88ffd4906879ee5ab9eea104d0dfb893496872aff119b47061e7160b62b376fe" + "manifest_sha256": "f3fdfecf1eb7c138f56e9f5e531bfed23b8d83e77dc8ce8194112ab6f9a0d374" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 99ea0d1a..d7bac85d 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -19,15 +19,13 @@ use std::collections::{BTreeMap, BTreeSet}; -use serde::{Deserialize, Serialize}; -use unicode_normalization::UnicodeNormalization; - use crate::exact_arithmetic::ExactDecimalAccumulator; use crate::master_binding::{ self, comparison_key, BindingStatus, Candidates, MasterBindingError, MasterCatalog, MasterClass, SourceEntity, }; use crate::{ExactDecimal, TallyDate}; +use serde::{Deserialize, Serialize}; /// Most vouchers one observed window may carry. A window past this is refused /// with a narrow-the-range error rather than silently compared in part. @@ -36,6 +34,18 @@ pub const MAX_WINDOW_VOUCHERS: usize = 20_000; pub const MAX_PROPOSED_VOUCHERS: usize = 5_000; /// Most ledger entries one voucher may carry. pub const MAX_ENTRIES_PER_VOUCHER: usize = 2_000; +/// Most distinct voucher-to-ledger memberships retained across one window. +/// +/// `WindowIndex` must retain every membership once more to find party +/// resemblances. Per-voucher limits alone therefore admitted 40 million +/// memberships. The cap keeps that derived index bounded rather than relying +/// on an allocator failure after a complete-looking input was accepted. +pub const MAX_WINDOW_LEDGER_MEMBERSHIPS: usize = 100_000; +/// Most UTF-8 bytes in the distinct ledger comparison keys across one window. +/// +/// This separately bounds a smaller number of very long accepted keys; a +/// membership count alone cannot do that. +pub const MAX_WINDOW_LEDGER_KEY_BYTES: usize = 4 * 1024 * 1024; /// Most candidates retained per undecided proposal. pub const MAX_CANDIDATES_PER_PROPOSAL: usize = 25; /// Most duplicate-number groups listed in the book observations. @@ -85,6 +95,10 @@ pub enum PresenceError { WindowRangeInvalid, #[error("book window exceeded its bound")] WindowTooLarge, + #[error("book window ledger memberships exceeded their aggregate bound")] + WindowLedgerMembershipsTooMany, + #[error("book window ledger keys exceeded their aggregate byte bound")] + WindowLedgerKeyBytesTooLarge, #[error("book window carried a voucher dated outside its own range")] WindowVoucherOutsideRange, #[error("book window carried the same voucher key twice")] @@ -136,6 +150,8 @@ impl PresenceError { Self::WindowIncomplete => "presence_window_incomplete", Self::WindowRangeInvalid => "presence_window_range_invalid", Self::WindowTooLarge => "presence_window_too_large", + Self::WindowLedgerMembershipsTooMany => "presence_window_ledger_memberships_too_many", + Self::WindowLedgerKeyBytesTooLarge => "presence_window_ledger_key_bytes_too_large", Self::WindowVoucherOutsideRange => "presence_window_voucher_outside_range", Self::WindowDuplicateVoucherKey => "presence_window_duplicate_voucher_key", Self::WindowRemoteIdContradiction => "presence_window_remote_id_contradiction", @@ -277,7 +293,10 @@ impl BookVoucher { if let Some(party) = party.as_deref() { ledger_keys.insert(comparison_key(party)); } - let type_key = comparison_key(&voucher_type); + // Voucher types participate in an identity key. Unlike ledger names, + // no source observation qualifies case, whitespace, or separator + // folding for them, so preserve their validated spelling exactly. + let type_key = voucher_type.clone(); let number_key = voucher_number.as_deref().map(number_key_of); Ok(Self { key, @@ -346,7 +365,7 @@ impl ProposedVoucher { let remote_id = input.remote_id.map(validated_text).transpose()?; let party = input.party.map(validated_text).transpose()?; let (magnitude, _, _) = magnitude_of(input.entries)?; - let type_key = comparison_key(&voucher_type); + let type_key = voucher_type.clone(); let number_key = voucher_number.as_deref().map(number_key_of); Ok(Self { position: input.position, @@ -416,6 +435,8 @@ impl BookWindow { return Err(PresenceError::WindowTooLarge); } let mut keys = BTreeSet::new(); + let mut ledger_memberships = 0usize; + let mut ledger_key_bytes = 0usize; for voucher in &vouchers { if voucher.date() < from.as_str() || voucher.date() > to.as_str() { return Err(PresenceError::WindowVoucherOutsideRange); @@ -426,6 +447,23 @@ impl BookWindow { if remote_id_evidence == RemoteIdEvidence::NotRead && voucher.remote_id.is_some() { return Err(PresenceError::WindowRemoteIdContradiction); } + ledger_memberships = ledger_memberships + .checked_add(voucher.ledger_keys.len()) + .ok_or(PresenceError::WindowLedgerMembershipsTooMany)?; + if ledger_memberships > MAX_WINDOW_LEDGER_MEMBERSHIPS { + return Err(PresenceError::WindowLedgerMembershipsTooMany); + } + let voucher_key_bytes = voucher + .ledger_keys + .iter() + .try_fold(0usize, |total, key| total.checked_add(key.len())) + .ok_or(PresenceError::WindowLedgerKeyBytesTooLarge)?; + ledger_key_bytes = ledger_key_bytes + .checked_add(voucher_key_bytes) + .ok_or(PresenceError::WindowLedgerKeyBytesTooLarge)?; + if ledger_key_bytes > MAX_WINDOW_LEDGER_KEY_BYTES { + return Err(PresenceError::WindowLedgerKeyBytesTooLarge); + } } Ok(Self { from, @@ -472,7 +510,7 @@ impl NumberingDeclaration { { let mut methods = BTreeMap::new(); for (voucher_type, method) in entries { - let key = comparison_key(&validated_text(voucher_type.as_ref())?); + let key = validated_text(voucher_type.as_ref())?; if methods .insert(key, method) .is_some_and(|prior| prior != method) @@ -487,7 +525,7 @@ impl NumberingDeclaration { /// caller to reproduce this crate's comparison key. A consumer validating /// its own arguments before performing a read uses this. pub fn declares(&self, voucher_type: &str) -> bool { - self.methods.contains_key(&comparison_key(voucher_type)) + self.methods.contains_key(voucher_type) } fn method(&self, type_key: &str) -> Option { @@ -1554,7 +1592,7 @@ fn differences( // Bounded for the same reason the observation labels are: a // response can drop whole rows but cannot shrink one, and the // comparison above already used the full values. - proposed: Some(label(catalog_name)), + proposed: proposal.party.as_deref().map(label), observed: Some(label(observed)), }); } @@ -1671,13 +1709,12 @@ fn label(value: &str) -> String { /// caller an invoice is already filed. Two distinct invoices numbered `a-1` /// and `A-1` would have suppressed one another. /// -/// What remains is encoding, not semantics. NFC and outer whitespace trimming -/// handle transport artefacts. Internal whitespace, case and punctuation are -/// content until something measures otherwise, and this narrows toward the -/// noisy failure: an unmatched punctuation variant reads as absent, which -/// costs a duplicate a person can see. +/// Only outer whitespace is a transport artefact in the current adapter. +/// Unicode composition, internal whitespace, case and punctuation are content +/// until something measures otherwise. This narrows toward the noisy failure: +/// an unmatched variant costs a duplicate a person can see. fn number_key_of(value: &str) -> String { - value.nfc().collect::().trim().to_string() + value.trim().to_string() } fn keep_strongest( diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 63795896..61b264c5 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -345,11 +345,23 @@ fn an_undeclared_numbering_method_is_an_error_not_a_default() { } #[test] -fn declaring_one_voucher_type_two_ways_is_refused() { +fn differently_spelled_voucher_types_have_independent_declarations() { + let declaration = NumberingDeclaration::new([ + ("Sales", NumberingMethod::Manual), + ("sales", NumberingMethod::Automatic), + ]) + .expect("distinct exact type names"); + assert!(declaration.declares("Sales")); + assert!(declaration.declares("sales")); + assert!(!declaration.declares(" SALES ")); +} + +#[test] +fn conflicting_declarations_of_the_same_exact_voucher_type_are_refused() { assert_eq!( NumberingDeclaration::new([ ("Sales", NumberingMethod::Manual), - ("sales", NumberingMethod::Automatic), + ("Sales", NumberingMethod::Automatic), ]) .expect_err("conflict"), PresenceError::NumberingMethodConflict @@ -663,6 +675,30 @@ fn a_present_voucher_reports_a_party_the_book_disagrees_with() { assert_eq!(party.observed.as_deref(), Some("Bravo Industries")); } +#[test] +fn a_party_difference_echoes_the_source_spelling_not_its_catalog_binding() { + let window = + window(&[BookRow::new("book-1", "20260812", "AA0118").party_field("Bravo Industries")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .party("alpha traders") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + let party = differences + .iter() + .find(|difference| difference.field == DifferenceField::Party) + .expect("party difference"); + assert_eq!(party.proposed.as_deref(), Some("alpha traders")); + assert_eq!(party.observed.as_deref(), Some("Bravo Industries")); +} + #[test] fn an_agreeing_present_voucher_reports_no_differences() { let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); @@ -1234,12 +1270,95 @@ fn observed_input_refuses_blank_unsafe_and_invalid_fields() { ); } +#[test] +fn a_window_bounds_aggregate_ledger_memberships_before_indexing() { + let ledgers = (0..MAX_ENTRIES_PER_VOUCHER) + .map(|position| Box::leak(format!("Ledger {position:04}").into_boxed_str()) as &'static str) + .collect::>(); + let entries = ledgers + .iter() + .map(|ledger| ObservedEntry { + ledger, + amount: "1.00", + }) + .collect::>(); + let vouchers = (0..(MAX_WINDOW_LEDGER_MEMBERSHIPS / MAX_ENTRIES_PER_VOUCHER + 1)) + .map(|position| { + BookVoucher::observed(ObservedVoucher { + key: Box::leak(format!("book-{position:03}").into_boxed_str()), + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: &entries, + cancelled: false, + optional: false, + }) + .expect("voucher below its own entry limit") + }) + .collect(); + assert_eq!( + BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + vouchers, + ) + .expect_err("derived index membership budget"), + PresenceError::WindowLedgerMembershipsTooMany + ); +} + +#[test] +fn a_window_bounds_aggregate_ledger_key_bytes_before_indexing() { + let ledgers = (0..(MAX_WINDOW_LEDGER_KEY_BYTES / MAX_TEXT_CHARS + 1)) + .map(|position| { + Box::leak(format!("{position:04}{}", "x".repeat(MAX_TEXT_CHARS - 4)).into_boxed_str()) + as &'static str + }) + .collect::>(); + let entries = ledgers + .iter() + .map(|ledger| ObservedEntry { + ledger, + amount: "1.00", + }) + .collect::>(); + let voucher = BookVoucher::observed(ObservedVoucher { + key: "book-1", + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: &entries, + cancelled: false, + optional: false, + }) + .expect("voucher below its own bounds"); + assert_eq!( + BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + vec![voucher], + ) + .expect_err("derived index key-byte budget"), + PresenceError::WindowLedgerKeyBytesTooLarge + ); +} + #[test] fn every_error_carries_a_distinct_stable_reason_code() { let codes = [ PresenceError::WindowIncomplete, PresenceError::WindowRangeInvalid, PresenceError::WindowTooLarge, + PresenceError::WindowLedgerMembershipsTooMany, + PresenceError::WindowLedgerKeyBytesTooLarge, PresenceError::WindowVoucherOutsideRange, PresenceError::WindowDuplicateVoucherKey, PresenceError::WindowDoesNotCover, @@ -1258,7 +1377,7 @@ fn every_error_carries_a_distinct_stable_reason_code() { .iter() .map(PresenceError::safe_reason_code) .collect::>(); - assert_eq!(codes.len(), 17); + assert_eq!(codes.len(), 19); assert!(codes.iter().all(|code| code.starts_with("presence_"))); } @@ -2079,6 +2198,43 @@ fn two_numbers_differing_only_in_case_are_two_numbers() { &spaced, ); assert_eq!(only(&report).present_book_key(), None); + + let composed = window(&[BookRow::new("book-1", "20260812", "Caf\u{00e9}-0118")]); + let decomposed = [ProposalRow::new(0, "20260812", "Cafe\u{0301}-0118") + .party("Bravo Industries") + .rows(vec![ + ["Bravo Industries", "-4200.00"], + ["Sales Account", "4200.00"], + ]) + .build()]; + let report = run( + &composed, + &catalog(), + &numbering(NumberingMethod::Manual), + &decomposed, + ); + assert_eq!( + only(&report).present_book_key(), + None, + "Unicode composition is part of a voucher number until Tally proves otherwise" + ); +} + +#[test] +fn a_manual_number_does_not_decide_across_differently_spelled_voucher_types() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .voucher_type("sales") + .build()]; + let declaration = + NumberingDeclaration::new([("sales", NumberingMethod::Manual)]).expect("numbering"); + let report = run(&window, &catalog(), &declaration, &proposals); + assert!(!only(&report).voucher_type_observed); + assert_eq!(only(&report).present_book_key(), None); + assert_eq!( + reason(only(&report)), + UndecidedReason::VoucherTypeNotObserved + ); } /// Under a `Manual` declaration the number is the one key that can decide, so diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs index b3e21f5b..a66fdb3b 100644 --- a/src-tauri/src/agent_presence.rs +++ b/src-tauri/src/agent_presence.rs @@ -121,6 +121,26 @@ impl Server { None, )?; + // The window is independent evidence about which ledgers exist. + // A row posting to an unlisted ledger proves the first catalogue + // short, regardless of whether a later window qualification could + // have authorised a verdict. Refuse before the nonempty hold so + // this distinct source defect remains visible without a redundant + // paired catalogue read. + for row in &rows { + let entry_ledgers = row["amounts"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(|entry| entry["ledger"].as_str()); + for ledger in row["party"].as_str().into_iter().chain(entry_ledgers) { + if catalog.exact(ledger).is_none() { + return Err("ledger_catalogue_incomplete".to_string().into()); + } + } + } + // A window can only license `Absent` when its cardinality is // independently established. The existing empty-window control // can establish that narrow case. A nonempty response has no @@ -146,6 +166,16 @@ impl Server { } else if let Some(evidence) = accumulated.as_mut() { evidence.state = "partial"; evidence.reason_code = reason.map(str::to_string); + // The adapter has no source-side cardinality for nonempty + // windows. A later catalogue reread cannot change the fixed + // `Partial` state into a complete observation, so avoid the + // extra endpoint load and fail with the evidence already in + // hand. A future qualified nonempty path can continue to the + // paired-snapshot checks below. + return Err(PresenceError::WindowIncomplete + .safe_reason_code() + .to_string() + .into()); } // The verdict is built from two independently timed observations, @@ -172,28 +202,6 @@ impl Server { return Err("ledger_snapshot_drifted".to_string().into()); } - // The window is independent evidence about which ledgers exist, - // and it is already in hand. A ledger the book posts to but the - // catalogue never listed proves the catalogue short -- both reads - // agreeing only proves they agree. Left unchecked, a proposal - // naming that ledger binds `Unmatched`, every party rule declines - // to run, and an `Absent` is authorised off a comparison that was - // never possible. That is the failure this whole contract exists - // to prevent, so it fails closed here rather than being reported. - for row in &rows { - let entry_ledgers = row["amounts"] - .as_array() - .map(Vec::as_slice) - .unwrap_or_default() - .iter() - .filter_map(|entry| entry["ledger"].as_str()); - for ledger in row["party"].as_str().into_iter().chain(entry_ledgers) { - if catalog.exact(ledger).is_none() { - return Err("ledger_catalogue_incomplete".to_string().into()); - } - } - } - let observed = rows .iter() .map(book_voucher) diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index 9d09168d..f71c8f4b 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -565,12 +565,11 @@ fn plans(steps: Vec) -> Vec { fn presence_plans() -> Vec { let catalogue = catalogue_xml(); let mut steps = vec![Step::Company, Step::Status, Step::Company, Step::Status]; - // Catalogue, then the voucher window, then the catalogue again: the - // verdict is built from two observations and the second read proves the - // first still holds. + // Catalogue, then the voucher window. A nonempty window lacks a + // source-side cardinality control and is refused before a paired + // catalogue snapshot could contribute to a verdict. steps.extend(paired_read(&catalogue)); steps.extend(paired_read(&window_xml())); - steps.extend(paired_read(&catalogue)); plans(steps) } @@ -621,7 +620,7 @@ async fn a_nonempty_window_without_a_control_total_refuses_to_issue_absent() { "partial" ); let observed = simulator.finish().expect("requests"); - assert_eq!(observed.len(), 22); + assert_eq!(observed.len(), 16); } /// The admission contract this tool enforces lives in `agent_catalog.rs`, and @@ -756,7 +755,6 @@ async fn a_ledger_missing_from_the_catalogue_fails_closed() { let mut steps = vec![Step::Company, Step::Status, Step::Company, Step::Status]; steps.extend(paired_read(&catalogue)); steps.extend(paired_read(&unlisted)); - steps.extend(paired_read(&catalogue)); let simulator = SequenceSimulator::spawn(plans(steps)).expect("simulator"); let directory = tempfile::tempdir().expect("directory"); let server = Server::new(Settings { From bc6c20c145433b44dfea835fcb39d95653c6032f Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 23:51:54 +0530 Subject: [PATCH 53/91] Fix voucher presence contract assertions --- docs/adr/0017-voucher-presence-authority.md | 19 +++++++++++-------- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 ++-- src-tauri/src/agent_presence_tests.rs | 18 +++++++++--------- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 8956038e..dd900419 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -101,9 +101,11 @@ date and amount, the pair this contract says collides. A proposal under a `Manual` declaration that supplies no voucher number (`ManualNumberNotSupplied`) has withheld the one key that could decide. And a proposal carrying a `REMOTEID` the window never read -(`RemoteIdEvidenceUnavailable`) had its strongest key skipped. None of these -blocks `Present` — identity still settles where it can; only the *absence* -claim is withheld, and supplying the missing field is what makes it available. +(`RemoteIdEvidenceUnavailable`) had its strongest key skipped. Missing party +and number cases do not block `Present` when another identity settles it. +Unread `REMOTEID` does: a manual-number match becomes `PossiblyPresent`, because +the two identity channels could contradict. In every case the *absence* claim +is withheld; supplying the missing evidence is what makes it available. Two binding outcomes withhold `Absent` outright: `NoDiscriminatingCandidate` (a name family that is deliberately not listed) and a truncated candidate list. @@ -444,11 +446,12 @@ human-approved batch — this ADR does not move. copies of every bound in the tree, and the copy that drifts is the one nobody is looking at. The helper lives beside the existing validator so the next tool with a nested schema reuses it rather than restating anything. -- Voucher-type names fold through `master_binding::comparison_key`. Voucher - numbers have a separate, deliberately narrower key: NFC plus outer transport - whitespace trimming only. Internal whitespace, case, and punctuation remain - content until voucher-number evidence establishes an equivalence; a broader - master-name fold could manufacture `Present` for two distinct invoices. +- Voucher-type names preserve their validated spelling exactly: they are Tally + identity, not master names. Voucher numbers have a separate, deliberately + narrower key: outer transport whitespace trimming only. Internal whitespace, + case, punctuation, and Unicode form remain content until voucher-number + evidence establishes an equivalence; a broader fold could manufacture + `Present` for two distinct invoices. - **The desktop source-draft flow is deliberately not wired yet, and the reason is a shape gap rather than a scheduling one.** A draft row carries a `source_remote_id`, a date, a voucher type and entries — but no voucher diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 0b720e6d..9d994067 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": "e6b590f3e6910407c3640b8dfdaffbb3caa98d96479f192c9a5080fbf5fc7062", + "compatibility_surface_sha256": "2f583286286153456921fa99ef56409f971fa6d65042b3fe820a563503b815d0", "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 e8ca44f9..6d84bcb9 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -347,7 +347,7 @@ }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "0783087fd025fcffe9034bc5c851d41c7941b318539da327a84e8b549a3135be" + "sha256": "a27bf51ab9138c1b4bae849d11f1821e34785245a7a00ccf51441a7c53812dfc" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "e6b590f3e6910407c3640b8dfdaffbb3caa98d96479f192c9a5080fbf5fc7062" + "manifest_sha256": "2f583286286153456921fa99ef56409f971fa6d65042b3fe820a563503b815d0" } \ No newline at end of file diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index f71c8f4b..eb1c4675 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -411,15 +411,15 @@ async fn nested_arguments_are_bounded_to_the_published_schema() { // --- typed parses ------------------------------------------------------- #[test] -fn one_voucher_type_declared_two_ways_is_refused_before_a_read() { - assert_eq!( - parse_numbering(&json!({"numbering":[ - {"voucher_type":"Journal","numbering_method":"manual"}, - {"voucher_type":"journal","numbering_method":"automatic"}, - ]})) - .expect_err("conflict"), - "presence_numbering_method_conflict".to_string() - ); +fn case_distinct_voucher_types_keep_independent_numbering_declarations() { + // Voucher types are identity, not master names: folding their case could + // let two distinct Tally types claim the same manual-number namespace. + // Exact duplicates with differing methods still fail in the core type. + parse_numbering(&json!({"numbering":[ + {"voucher_type":"Journal","numbering_method":"manual"}, + {"voucher_type":"journal","numbering_method":"automatic"}, + ]})) + .expect("case-distinct voucher types are independent"); } #[test] From d648b9752f649b5bd3024eab2c1fb0f767b94699 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 12 Sep 2026 23:56:03 +0530 Subject: [PATCH 54/91] Reject incomplete voucher presence input --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 10 +++++----- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + .../bridge-tally-core/src/book_presence.rs | 6 ++++++ .../src/book_presence_tests.rs | 17 +++++++++++++++++ src-tauri/src/agent_catalog.rs | 13 +++++++++---- src-tauri/src/agent_presence_tests.rs | 7 +++++++ 8 files changed, 47 insertions(+), 10 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 9d994067..f1b5b27d 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": "2f583286286153456921fa99ef56409f971fa6d65042b3fe820a563503b815d0", + "compatibility_surface_sha256": "a59f859a5c76df36027e728ca7466b72d67d7304f51aa0d42b029f3555cea334", "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 6d84bcb9..d9dccb7f 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -123,11 +123,11 @@ }, { "path": "src-tauri/Cargo.lock", - "sha256": "1b6484e09fa0cc08355dfc0cccda5abbbb4651426081401ab63caccf372b5245" + "sha256": "b89de1a745a8ef32477eada8fc4fce0733758bc7b9ebe40ecf88e577b2bb6f23" }, { "path": "src-tauri/Cargo.toml", - "sha256": "2914df522809bc855c092f16dd1ae75b6766bf5bf0a223a1de482795233dde79" + "sha256": "8cfb5dd2f70487efd46742809883c78ae8782dce9ec239e6d74cbfec989577ac" }, { "path": "src-tauri/crates/bridge-tally-core/Cargo.toml", @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "6e0094161a087efd2f919acfcff08d43f90558886e8b64449828c21d6b2f70fa" + "sha256": "5b265bdda648e723e9351dfbdc4a6f062550fb1fc403bbba759c2f6de4209ef3" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -347,7 +347,7 @@ }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "a27bf51ab9138c1b4bae849d11f1821e34785245a7a00ccf51441a7c53812dfc" + "sha256": "9120b76ccca4164695eee5ece22a8b598caa39568799ec48c8f19e86a5312fa8" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "2f583286286153456921fa99ef56409f971fa6d65042b3fe820a563503b815d0" + "manifest_sha256": "a59f859a5c76df36027e728ca7466b72d67d7304f51aa0d42b029f3555cea334" } \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a8cc6ac5..4d669416 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -425,6 +425,7 @@ dependencies = [ "pdf-writer", "pkcs11", "quick-xml", + "regex", "reqwest", "rfd", "rust_xlsxwriter", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 141d5112..89a0b4ee 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -73,6 +73,7 @@ keyring = { version = "4.1.4", default-features = false, features = ["v1"] } pkcs11 = "0.5" quick-xml = { version = "0.41", features = ["serialize"] } reqwest = { version = "0.13", features = ["json", "stream"] } +regex = "1" # Bridge supports Windows and macOS; rfd's defaults add Linux portal/Wayland code. rfd = { version = "0.17", default-features = false } rust_xlsxwriter = { version = "0.99.0", default-features = false } diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index d7bac85d..83c234fe 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -118,6 +118,8 @@ pub enum PresenceError { ProposalsEmpty, #[error("proposed voucher list exceeded its bound")] TooManyProposals, + #[error("voucher entry list was empty")] + EntriesEmpty, #[error("voucher entry list exceeded its bound")] TooManyEntries, /// A voucher type whose numbering method nobody stated. Defaulting it @@ -159,6 +161,7 @@ impl PresenceError { Self::WindowDoesNotCover => "presence_window_does_not_cover", Self::ProposalsEmpty => "presence_proposals_empty", Self::TooManyProposals => "presence_proposals_too_many", + Self::EntriesEmpty => "presence_entries_empty", Self::TooManyEntries => "presence_entries_too_many", Self::NumberingMethodUndeclared => "presence_numbering_method_undeclared", Self::NumberingMethodConflict => "presence_numbering_method_conflict", @@ -1795,6 +1798,9 @@ fn undecided( fn magnitude_of( entries: &[ObservedEntry<'_>], ) -> Result<(ExactDecimal, bool, BTreeSet), PresenceError> { + if entries.is_empty() { + return Err(PresenceError::EntriesEmpty); + } if entries.len() > MAX_ENTRIES_PER_VOUCHER { return Err(PresenceError::TooManyEntries); } diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 61b264c5..25082ce6 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -971,6 +971,23 @@ fn a_proposal_without_a_party_still_runs_the_party_independent_rules() { // --- magnitude --------------------------------------------------------- +#[test] +fn an_empty_proposal_entry_list_is_refused_at_the_core_boundary() { + assert_eq!( + ProposedVoucher::new(ProposedVoucherInput { + position: 0, + date: "20260812", + voucher_type: "Sales", + voucher_number: Some("AA0118"), + remote_id: None, + party: None, + entries: &[], + }) + .expect_err("empty accounting data"), + PresenceError::EntriesEmpty + ); +} + #[test] fn both_sides_derive_one_magnitude_from_the_same_entries() { let window = window(&[BookRow::new("book-1", "20260812", "AA0118").rows(vec![ diff --git a/src-tauri/src/agent_catalog.rs b/src-tauri/src/agent_catalog.rs index e039686c..26b39c22 100644 --- a/src-tauri/src/agent_catalog.rs +++ b/src-tauri/src/agent_catalog.rs @@ -1,5 +1,6 @@ //! Public tool catalog and argument admission before any Tally read. use super::*; +use regex::Regex; pub(super) fn validate_tool_arguments(name: &str, args: &Value) -> Result<(), String> { let arguments = args @@ -100,9 +101,9 @@ pub(super) fn validate_tool_arguments(name: &str, args: &Value) -> Result<(), St /// drift, and the copy that drifts is the one nobody is looking at. /// /// It enforces exactly what the fragment states — `type`, `enum`, string -/// bounds, array bounds, `required`, and `additionalProperties: false` — and -/// nothing it does not, so a schema remains the single description of what a -/// caller may send. +/// bounds and patterns, array bounds, `required`, and `additionalProperties: +/// false` — and nothing it does not, so a schema remains the single +/// description of what a caller may send. pub(super) fn validate_against_schema( value: &Value, schema: &Value, @@ -176,7 +177,11 @@ fn validate_string_bounds(text: &str, schema: &Value, key: &str) -> Result<(), S || schema["maxLength"] .as_u64() .is_some_and(|max| length > max as usize) - || (schema["pattern"] == r"\S" && text.trim().is_empty()) + || schema["pattern"].as_str().is_some_and(|pattern| { + Regex::new(pattern) + .map(|regex| !regex.is_match(text)) + .unwrap_or(true) + }) { return Err(format!("argument_invalid:{key}")); } diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index eb1c4675..b97823c4 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -90,6 +90,13 @@ async fn presence_arguments_are_bounded_before_any_tally_probe() { "vouchers":[{"date":"20260901","voucher_type":"Journal","entries":[]}]}), "argument_invalid:vouchers", ), + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[{"date":"2-0-2-6-0-9-0-1","voucher_type":"Journal", + "entries":[{"ledger":"Cash","amount":"-1.00"},{"ledger":"WR2 Sales","amount":"1.00"}]}]}), + "argument_invalid:vouchers", + ), ( json!({"company_guid":GUID,"from":"20260901","to":"20260930", "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], From 7a561b024235210e2cdc80603b884fa6be32457b Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 00:03:34 +0530 Subject: [PATCH 55/91] fix: bound published schema pattern admission --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 6 +-- src-tauri/Cargo.lock | 1 - src-tauri/Cargo.toml | 1 - src-tauri/src/agent_admission_tests.rs | 49 +++++++++++++++++++ src-tauri/src/agent_catalog.rs | 44 ++++++++++++++--- 6 files changed, 91 insertions(+), 12 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index f1b5b27d..4ff3cb59 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": "a59f859a5c76df36027e728ca7466b72d67d7304f51aa0d42b029f3555cea334", + "compatibility_surface_sha256": "af1557e5c452ac4d094558d5dcefea83bc223927cb9357ab96ac7ffe1fb86967", "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 d9dccb7f..4c73c8d3 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -123,11 +123,11 @@ }, { "path": "src-tauri/Cargo.lock", - "sha256": "b89de1a745a8ef32477eada8fc4fce0733758bc7b9ebe40ecf88e577b2bb6f23" + "sha256": "1b6484e09fa0cc08355dfc0cccda5abbbb4651426081401ab63caccf372b5245" }, { "path": "src-tauri/Cargo.toml", - "sha256": "8cfb5dd2f70487efd46742809883c78ae8782dce9ec239e6d74cbfec989577ac" + "sha256": "2914df522809bc855c092f16dd1ae75b6766bf5bf0a223a1de482795233dde79" }, { "path": "src-tauri/crates/bridge-tally-core/Cargo.toml", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "a59f859a5c76df36027e728ca7466b72d67d7304f51aa0d42b029f3555cea334" + "manifest_sha256": "af1557e5c452ac4d094558d5dcefea83bc223927cb9357ab96ac7ffe1fb86967" } \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4d669416..a8cc6ac5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -425,7 +425,6 @@ dependencies = [ "pdf-writer", "pkcs11", "quick-xml", - "regex", "reqwest", "rfd", "rust_xlsxwriter", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 89a0b4ee..141d5112 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -73,7 +73,6 @@ keyring = { version = "4.1.4", default-features = false, features = ["v1"] } pkcs11 = "0.5" quick-xml = { version = "0.41", features = ["serialize"] } reqwest = { version = "0.13", features = ["json", "stream"] } -regex = "1" # Bridge supports Windows and macOS; rfd's defaults add Linux portal/Wayland code. rfd = { version = "0.17", default-features = false } rust_xlsxwriter = { version = "0.99.0", default-features = false } diff --git a/src-tauri/src/agent_admission_tests.rs b/src-tauri/src/agent_admission_tests.rs index 25f5a11c..fb8612bc 100644 --- a/src-tauri/src/agent_admission_tests.rs +++ b/src-tauri/src/agent_admission_tests.rs @@ -1,5 +1,54 @@ use super::*; +#[test] +fn published_pattern_inventory_preserves_the_admitted_wire_shapes() { + let accepted_dates = ["20260901", "2026-09-01", "2026-0901", "202609-01"]; + for date in accepted_dates { + assert!(published_pattern_matches(DATE_WIRE_PATTERN, date), "{date}"); + } + for rejected in ["2-0-2-6-0-9-0-1", "2026/09/01", "2026090", "202609011"] { + assert!( + !published_pattern_matches(DATE_WIRE_PATTERN, rejected), + "{rejected}" + ); + } + assert!(published_pattern_matches( + NONBLANK_PATTERN, + "\u{2003}ledger" + )); + assert!(!published_pattern_matches(NONBLANK_PATTERN, " \u{2003}\t")); + + fn patterns(value: &Value, found: &mut Vec) { + match value { + Value::Object(object) => { + if let Some(pattern) = object.get("pattern").and_then(Value::as_str) { + found.push(pattern.to_string()); + } + for child in object.values() { + patterns(child, found); + } + } + Value::Array(values) => { + for child in values { + patterns(child, found); + } + } + _ => {} + } + } + + let definitions = registered_tool_definitions(true, true); + let schema = definitions + .as_array() + .and_then(|tools| tools.iter().find(|tool| tool["name"] == "voucher_presence")) + .expect("voucher_presence tool"); + let mut found = Vec::new(); + patterns(&schema["inputSchema"], &mut found); + found.sort(); + found.dedup(); + assert_eq!(found, vec![NONBLANK_PATTERN, DATE_WIRE_PATTERN]); +} + #[tokio::test] async fn voucher_type_selector_is_bounded_before_any_tally_read() { let directory = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/agent_catalog.rs b/src-tauri/src/agent_catalog.rs index 26b39c22..190ed203 100644 --- a/src-tauri/src/agent_catalog.rs +++ b/src-tauri/src/agent_catalog.rs @@ -1,6 +1,8 @@ //! Public tool catalog and argument admission before any Tally read. use super::*; -use regex::Regex; + +const NONBLANK_PATTERN: &str = r"\S"; +const DATE_WIRE_PATTERN: &str = "^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"; pub(super) fn validate_tool_arguments(name: &str, args: &Value) -> Result<(), String> { let arguments = args @@ -177,17 +179,47 @@ fn validate_string_bounds(text: &str, schema: &Value, key: &str) -> Result<(), S || schema["maxLength"] .as_u64() .is_some_and(|max| length > max as usize) - || schema["pattern"].as_str().is_some_and(|pattern| { - Regex::new(pattern) - .map(|regex| !regex.is_match(text)) - .unwrap_or(true) - }) + || schema["pattern"] + .as_str() + .is_some_and(|pattern| !published_pattern_matches(pattern, text)) { return Err(format!("argument_invalid:{key}")); } Ok(()) } +/// Recognize the finite pattern vocabulary in the published local-tool schema. +/// +/// Pattern text is schema authority, but accepting an arbitrary new expression +/// would add an unbounded compile/cache decision to the admission path. Unknown +/// patterns therefore refuse input until their exact wire shape is implemented +/// and reviewed here. Calendar validity stays with `normalized_date` at the +/// typed boundary; this only preserves the published lexical shape. +fn published_pattern_matches(pattern: &str, text: &str) -> bool { + match pattern { + NONBLANK_PATTERN => text.chars().any(|character| !character.is_whitespace()), + DATE_WIRE_PATTERN => { + let bytes = text.as_bytes(); + let Some((year, remainder)) = bytes.split_at_checked(4) else { + return false; + }; + if !year.iter().all(u8::is_ascii_digit) { + return false; + } + let remainder = remainder.strip_prefix(b"-").unwrap_or(remainder); + let Some((month, remainder)) = remainder.split_at_checked(2) else { + return false; + }; + if !month.iter().all(u8::is_ascii_digit) { + return false; + } + let remainder = remainder.strip_prefix(b"-").unwrap_or(remainder); + remainder.len() == 2 && remainder.iter().all(u8::is_ascii_digit) + } + _ => false, + } +} + pub(super) fn tool_definitions(import_enabled: bool, writes_enabled: bool) -> Value { let mut definitions = registered_tool_definitions(import_enabled, writes_enabled); definitions From 0ab76322908ca2b2b699c0edadf8ed5a535fcf31 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 00:39:11 +0530 Subject: [PATCH 56/91] Harden voucher presence evidence boundaries --- docs/adr/0017-voucher-presence-authority.md | 2 +- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 8 ++-- .../bridge-tally-core/src/book_presence.rs | 35 ++++++++++++++++++ .../src/book_presence_tests.rs | 37 +++++++++++++++++++ src-tauri/src/agent.rs | 5 ++- src-tauri/src/agent_tests.rs | 2 +- 7 files changed, 83 insertions(+), 8 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index dd900419..494560d3 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -187,7 +187,7 @@ Per proposed voucher, exactly one of: | status | meaning | what it authorises | | --- | --- | --- | -| `Present { matched, basis, differences }` | An identity key matched, uniquely on both sides | excluding this voucher from the import | +| `Present { book_key, basis, differences }` | An identity key matched, uniquely on both sides | excluding this voucher from the import | | `PossiblyPresent { reason, candidates, .. }` | Something resembles it, or something prevented a decision | **nothing** | | `Absent` | No rule produced any candidate, in a window proven to cover it | including this voucher in the import | diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 4ff3cb59..20c662f9 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": "af1557e5c452ac4d094558d5dcefea83bc223927cb9357ab96ac7ffe1fb86967", + "compatibility_surface_sha256": "e3592905518f3fd2e5d649c92c2e03d5ff14fce555af89297e81f3d7bc065078", "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 4c73c8d3..09129a5f 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "5b265bdda648e723e9351dfbdc4a6f062550fb1fc403bbba759c2f6de4209ef3" + "sha256": "577baf4eb2f9f3ea075f53b14a99a52146845d2eead33061ce6a6a5bbf3290ab" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -327,7 +327,7 @@ }, { "path": "src-tauri/src/agent.rs", - "sha256": "e92609482da823a1c64974ed4995a67bed4ea946b323774b524b8def35ec7b52" + "sha256": "2132f9735c9ffe79262ad84a83c99c899fde4c37a809420877e5db83dcd3bcc4" }, { "path": "src-tauri/src/agent_desktop_journal.rs", @@ -363,7 +363,7 @@ }, { "path": "src-tauri/src/agent_tests.rs", - "sha256": "169f0e0b38cf9f2a070104191bbad99bca98c1caca40b07469c3bed4faae6a21" + "sha256": "c9d6b7722fe687d968c94c5669a1713a2aec276038fcd6facc47285d95e76726" }, { "path": "src-tauri/src/agent_trial_balance.rs", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "af1557e5c452ac4d094558d5dcefea83bc223927cb9357ab96ac7ffe1fb86967" + "manifest_sha256": "e3592905518f3fd2e5d649c92c2e03d5ff14fce555af89297e81f3d7bc065078" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 83c234fe..87813f7f 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -32,6 +32,10 @@ use serde::{Deserialize, Serialize}; pub const MAX_WINDOW_VOUCHERS: usize = 20_000; /// Most vouchers one proposal set may carry. pub const MAX_PROPOSED_VOUCHERS: usize = 5_000; +/// Most numbering declarations consumed for one presence request. +pub const MAX_NUMBERING_DECLARATIONS: usize = MAX_PROPOSED_VOUCHERS; +/// Aggregate UTF-8 bytes accepted while consuming numbering declarations. +pub const MAX_NUMBERING_DECLARATION_BYTES: usize = 1_048_576; /// Most ledger entries one voucher may carry. pub const MAX_ENTRIES_PER_VOUCHER: usize = 2_000; /// Most distinct voucher-to-ledger memberships retained across one window. @@ -128,6 +132,10 @@ pub enum PresenceError { NumberingMethodUndeclared, #[error("a voucher type was declared twice with different numbering")] NumberingMethodConflict, + #[error("numbering declarations exceeded their count bound")] + NumberingDeclarationsTooMany, + #[error("numbering declarations exceeded their aggregate byte bound")] + NumberingDeclarationBytesTooLarge, #[error("text field was blank")] TextBlank, #[error("text field exceeded its bound")] @@ -141,6 +149,8 @@ pub enum PresenceError { /// Presence compares party names against ledgers. #[error("master catalog was not a ledger catalog")] CatalogClassInvalid, + #[error("book window referenced a ledger absent from the catalog")] + CatalogWindowCoverageMissing, #[error("party binding refused the input")] PartyBinding(MasterBindingError), } @@ -165,12 +175,15 @@ impl PresenceError { Self::TooManyEntries => "presence_entries_too_many", Self::NumberingMethodUndeclared => "presence_numbering_method_undeclared", Self::NumberingMethodConflict => "presence_numbering_method_conflict", + Self::NumberingDeclarationsTooMany => "presence_numbering_declarations_too_many", + Self::NumberingDeclarationBytesTooLarge => "presence_numbering_declaration_bytes_too_large", Self::TextBlank => "presence_text_blank", Self::TextTooLong => "presence_text_too_long", Self::TextUnsafe => "presence_text_unsafe", Self::DateInvalid => "presence_date_invalid", Self::AmountInvalid => "presence_amount_invalid", Self::CatalogClassInvalid => "presence_catalog_class_invalid", + Self::CatalogWindowCoverageMissing => "presence_catalog_window_coverage_missing", Self::PartyBinding(error) => error.safe_reason_code(), } } @@ -512,8 +525,22 @@ impl NumberingDeclaration { S: AsRef, { let mut methods = BTreeMap::new(); + let mut declaration_count = 0usize; + let mut declaration_bytes = 0usize; for (voucher_type, method) in entries { let key = validated_text(voucher_type.as_ref())?; + declaration_count = declaration_count + .checked_add(1) + .ok_or(PresenceError::NumberingDeclarationsTooMany)?; + if declaration_count > MAX_NUMBERING_DECLARATIONS { + return Err(PresenceError::NumberingDeclarationsTooMany); + } + declaration_bytes = declaration_bytes + .checked_add(key.len()) + .ok_or(PresenceError::NumberingDeclarationBytesTooLarge)?; + if declaration_bytes > MAX_NUMBERING_DECLARATION_BYTES { + return Err(PresenceError::NumberingDeclarationBytesTooLarge); + } if methods .insert(key, method) .is_some_and(|prior| prior != method) @@ -895,6 +922,14 @@ impl<'a> PresenceRequest<'a> { if catalog.class() != MasterClass::Ledger { return Err(PresenceError::CatalogClassInvalid); } + if window + .vouchers() + .iter() + .flat_map(|voucher| voucher.ledger_keys.iter()) + .any(|ledger| catalog.exact(ledger).is_none()) + { + return Err(PresenceError::CatalogWindowCoverageMissing); + } if proposals.is_empty() { return Err(PresenceError::ProposalsEmpty); } diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 25082ce6..433f331a 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -377,6 +377,43 @@ fn a_repeated_identical_declaration_is_accepted() { .is_ok()); } +#[test] +fn numbering_declarations_bound_duplicate_iterator_work() { + let entries = (0..=MAX_NUMBERING_DECLARATIONS) + .map(|_| ("Sales", NumberingMethod::Manual)); + assert_eq!( + NumberingDeclaration::new(entries).expect_err("declaration count is bounded"), + PresenceError::NumberingDeclarationsTooMany + ); +} + +#[test] +fn numbering_declarations_bound_aggregate_bytes_while_consuming_duplicates() { + let entries = (0..) + .map(|_| ("X".repeat(MAX_TEXT_CHARS), NumberingMethod::Manual)); + assert_eq!( + NumberingDeclaration::new(entries).expect_err("declaration bytes are bounded"), + PresenceError::NumberingDeclarationBytesTooLarge + ); +} + +#[test] +fn request_refuses_a_window_ledger_missing_from_its_catalog() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118") + .rows(vec![["Uncatalogued Ledger", "0.00"]])]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + assert_eq!( + PresenceRequest::new( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals + ) + .expect_err("window ledger is absent from catalog"), + PresenceError::CatalogWindowCoverageMissing + ); +} + // --- only identity produces Present ------------------------------------ #[test] diff --git a/src-tauri/src/agent.rs b/src-tauri/src/agent.rs index fb8bd015..30d438d4 100644 --- a/src-tauri/src/agent.rs +++ b/src-tauri/src/agent.rs @@ -941,7 +941,10 @@ fn corroborate_empty_voucher_window( return Err("window_contradicted".to_string()); } if !widened_rows.is_empty() { - return Ok((false, None)); + // Boundary-day rows only prove that this wider read returned *some* + // data. They provide no independent cardinality for the nonempty + // response, so they cannot promote the original empty window. + return Ok((true, Some("nonempty_uncorroborated"))); } match company_high_water { Some(0) => Ok((false, Some("company_has_no_vouchers"))), diff --git a/src-tauri/src/agent_tests.rs b/src-tauri/src/agent_tests.rs index 25edc309..c86725fb 100644 --- a/src-tauri/src/agent_tests.rs +++ b/src-tauri/src/agent_tests.rs @@ -1502,7 +1502,7 @@ fn empty_voucher_window_corroboration_handles_all_three_control_branches() { "20260902", None, ), - Ok((false, None)) + Ok((true, Some("nonempty_uncorroborated"))) ); assert_eq!( corroborate_empty_voucher_window( From ff8ed1b25b11483e64159307daf64a573457f9f6 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 00:43:08 +0530 Subject: [PATCH 57/91] Cover complete presence catalogs in rule tests --- .../src/book_presence_tests.rs | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 433f331a..e91d9a35 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -202,7 +202,21 @@ fn run( numbering: &NumberingDeclaration, proposals: &[ProposedVoucher], ) -> PresenceReport { - let request = PresenceRequest::new(window, catalog, numbering, proposals).expect("request"); + // These rule tests name only the ledgers relevant to their assertion. The + // public boundary now requires a complete catalog, so complete that test + // fixture from the already-observed window rather than weakening the + // boundary every test reaches through this helper. + let mut names = catalog.names().map(str::to_owned).collect::>(); + names.extend( + window + .vouchers() + .iter() + .flat_map(|voucher| voucher.ledger_keys.iter().cloned()), + ); + names.sort(); + names.dedup(); + let complete_catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("complete catalog"); + let request = PresenceRequest::new(window, &complete_catalog, numbering, proposals).expect("request"); assess(&request) } @@ -848,7 +862,7 @@ fn candidates_are_ordered_by_rule_then_key_and_never_by_similarity() { // --- party matching is master_binding ----------------------------------- #[test] -fn a_party_binds_on_an_embedded_identifier_before_any_name() { +fn a_complete_catalogue_keeps_an_embedded_identifier_ambiguity_unresolved() { let names = [ "Alpha (5550000001)", "Alpha Traders", @@ -880,10 +894,14 @@ fn a_party_binds_on_an_embedded_identifier_before_any_name() { &proposals, ); let entry = only(&report); + // Completing the catalogue with the observed window spelling introduces a + // second holder for this identifier. That is genuine ambiguity, not a + // reason to omit the observed ledger from exact coverage. assert_eq!( entry.party, - PartyOutcome::Bound { - catalog_name: "Alpha (5550000001)".to_string() + PartyOutcome::Ambiguous { + reason: "master_binding_identifier_conflict".to_string(), + candidate_count: 3, } ); assert_eq!( From 43dbbfc0ae97bbb173cb0199dbe838894ef2eb42 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 00:49:49 +0530 Subject: [PATCH 58/91] Keep exact observed ledger coverage --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../bridge-tally-core/src/book_presence.rs | 26 +++++++++---- .../src/book_presence_tests.rs | 39 +++++++++++++++---- 4 files changed, 53 insertions(+), 18 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 20c662f9..9f476d3f 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": "e3592905518f3fd2e5d649c92c2e03d5ff14fce555af89297e81f3d7bc065078", + "compatibility_surface_sha256": "6e37a277baf4d1a01747a6fb88a389784dbaf119d6b14f93df6882e8574560cd", "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 09129a5f..58dd81f9 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "577baf4eb2f9f3ea075f53b14a99a52146845d2eead33061ce6a6a5bbf3290ab" + "sha256": "9efbf7e48c37c8bae9ae00314cd8e0c371e28221f00f6671bcbc7d0b98067364" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "e3592905518f3fd2e5d649c92c2e03d5ff14fce555af89297e81f3d7bc065078" + "manifest_sha256": "6e37a277baf4d1a01747a6fb88a389784dbaf119d6b14f93df6882e8574560cd" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 87813f7f..fd1f5e3d 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -285,6 +285,7 @@ pub struct BookVoucher { voucher_number: Option, remote_id: Option, party: Option, + observed_ledgers: BTreeSet, ledger_keys: BTreeSet, magnitude: ExactDecimal, balanced: bool, @@ -305,8 +306,9 @@ impl BookVoucher { let voucher_number = input.voucher_number.map(validated_text).transpose()?; let remote_id = input.remote_id.map(validated_text).transpose()?; let party = input.party.map(validated_text).transpose()?; - let (magnitude, balanced, mut ledger_keys) = magnitude_of(input.entries)?; + let (magnitude, balanced, mut observed_ledgers, mut ledger_keys) = magnitude_of(input.entries)?; if let Some(party) = party.as_deref() { + observed_ledgers.insert(party.to_string()); ledger_keys.insert(comparison_key(party)); } // Voucher types participate in an identity key. Unlike ledger names, @@ -321,6 +323,7 @@ impl BookVoucher { voucher_number, remote_id, party, + observed_ledgers, ledger_keys, magnitude, balanced, @@ -380,7 +383,7 @@ impl ProposedVoucher { let voucher_number = input.voucher_number.map(validated_text).transpose()?; let remote_id = input.remote_id.map(validated_text).transpose()?; let party = input.party.map(validated_text).transpose()?; - let (magnitude, _, _) = magnitude_of(input.entries)?; + let (magnitude, _, _, _) = magnitude_of(input.entries)?; let type_key = voucher_type.clone(); let number_key = voucher_number.as_deref().map(number_key_of); Ok(Self { @@ -463,8 +466,13 @@ impl BookWindow { if remote_id_evidence == RemoteIdEvidence::NotRead && voucher.remote_id.is_some() { return Err(PresenceError::WindowRemoteIdContradiction); } + let retained_memberships = voucher + .ledger_keys + .len() + .checked_add(voucher.observed_ledgers.len()) + .ok_or(PresenceError::WindowLedgerMembershipsTooMany)?; ledger_memberships = ledger_memberships - .checked_add(voucher.ledger_keys.len()) + .checked_add(retained_memberships) .ok_or(PresenceError::WindowLedgerMembershipsTooMany)?; if ledger_memberships > MAX_WINDOW_LEDGER_MEMBERSHIPS { return Err(PresenceError::WindowLedgerMembershipsTooMany); @@ -472,6 +480,7 @@ impl BookWindow { let voucher_key_bytes = voucher .ledger_keys .iter() + .chain(voucher.observed_ledgers.iter()) .try_fold(0usize, |total, key| total.checked_add(key.len())) .ok_or(PresenceError::WindowLedgerKeyBytesTooLarge)?; ledger_key_bytes = ledger_key_bytes @@ -925,7 +934,7 @@ impl<'a> PresenceRequest<'a> { if window .vouchers() .iter() - .flat_map(|voucher| voucher.ledger_keys.iter()) + .flat_map(|voucher| voucher.observed_ledgers.iter()) .any(|ledger| catalog.exact(ledger).is_none()) { return Err(PresenceError::CatalogWindowCoverageMissing); @@ -1832,7 +1841,7 @@ fn undecided( /// which is defined whether or not the voucher balances. fn magnitude_of( entries: &[ObservedEntry<'_>], -) -> Result<(ExactDecimal, bool, BTreeSet), PresenceError> { +) -> Result<(ExactDecimal, bool, BTreeSet, BTreeSet), PresenceError> { if entries.is_empty() { return Err(PresenceError::EntriesEmpty); } @@ -1841,6 +1850,7 @@ fn magnitude_of( } let mut total = ExactDecimalAccumulator::default(); let mut positive = ExactDecimalAccumulator::default(); + let mut observed_ledgers = BTreeSet::new(); let mut ledger_keys = BTreeSet::new(); for entry in entries { let amount = ExactDecimal::parse(entry.amount.to_string()) @@ -1849,11 +1859,13 @@ fn magnitude_of( if !amount.is_negative() { positive.add(amount.as_str()); } - ledger_keys.insert(comparison_key(&validated_text(entry.ledger)?)); + let ledger = validated_text(entry.ledger)?; + observed_ledgers.insert(ledger.clone()); + ledger_keys.insert(comparison_key(&ledger)); } let magnitude = ExactDecimal::parse(positive.canonical_string()) .map_err(|_| PresenceError::AmountInvalid)?; - Ok((magnitude, total.is_zero(), ledger_keys)) + Ok((magnitude, total.is_zero(), observed_ledgers, ledger_keys)) } fn validated_text(value: &str) -> Result { diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index e91d9a35..c6157eb4 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -211,7 +211,7 @@ fn run( window .vouchers() .iter() - .flat_map(|voucher| voucher.ledger_keys.iter().cloned()), + .flat_map(|voucher| voucher.observed_ledgers.iter().cloned()), ); names.sort(); names.dedup(); @@ -428,6 +428,33 @@ fn request_refuses_a_window_ledger_missing_from_its_catalog() { ); } +#[test] +fn request_coverage_uses_the_exact_observed_spelling() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118") + .party_field("Café") + .rows(vec![["Café", "0.00"]])]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let exact = catalog_of(&["Café"]); + assert!(PresenceRequest::new( + &window, + &exact, + &numbering(NumberingMethod::Manual), + &proposals + ) + .is_ok()); + let folded_only = catalog_of(&["café"]); + assert_eq!( + PresenceRequest::new( + &window, + &folded_only, + &numbering(NumberingMethod::Manual), + &proposals + ) + .expect_err("folded spelling is not exact coverage"), + PresenceError::CatalogWindowCoverageMissing + ); +} + // --- only identity produces Present ------------------------------------ #[test] @@ -862,7 +889,7 @@ fn candidates_are_ordered_by_rule_then_key_and_never_by_similarity() { // --- party matching is master_binding ----------------------------------- #[test] -fn a_complete_catalogue_keeps_an_embedded_identifier_ambiguity_unresolved() { +fn a_party_binds_on_an_embedded_identifier_before_any_name() { let names = [ "Alpha (5550000001)", "Alpha Traders", @@ -894,14 +921,10 @@ fn a_complete_catalogue_keeps_an_embedded_identifier_ambiguity_unresolved() { &proposals, ); let entry = only(&report); - // Completing the catalogue with the observed window spelling introduces a - // second holder for this identifier. That is genuine ambiguity, not a - // reason to omit the observed ledger from exact coverage. assert_eq!( entry.party, - PartyOutcome::Ambiguous { - reason: "master_binding_identifier_conflict".to_string(), - candidate_count: 3, + PartyOutcome::Bound { + catalog_name: "Alpha (5550000001)".to_string() } ); assert_eq!( From 7f688075c535b43e3b5d1bbed5caf55456b78399 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 00:53:26 +0530 Subject: [PATCH 59/91] Document exact presence catalog coverage --- docs/adr/0017-voucher-presence-authority.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 494560d3..f3a51842 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -423,6 +423,10 @@ human-approved batch — this ADR does not move. qualified ledger-catalogue and `vouchers` window reads, refuses to build a window from a partial read, and shapes the report through the same party-name marking and egress redaction as every other read result. +- **Catalog coverage is byte-exact.** The typed boundary retains each observed + ledger and party spelling separately from its folded resemblance key, and + rejects a window whose exact spelling is absent from the catalog. A candidate + fold can never stand in for coverage. - **The verdict is built from two independently timed reads, so the catalogue is corroborated after the window.** A ledger renamed between them would let a proposal bind the old name while the rows carry the new one, removing the From d444c9080f61586f4e2e855b2a45c349dcad312c Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 01:28:42 +0530 Subject: [PATCH 60/91] fix(presence): refuse conflicting identity and bound comparisons --- .../bridge-tally-core/src/book_presence.rs | 20 +++++-- .../src/book_presence_tests.rs | 53 +++++++++++++++---- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index fd1f5e3d..472ee9f7 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -32,6 +32,10 @@ use serde::{Deserialize, Serialize}; pub const MAX_WINDOW_VOUCHERS: usize = 20_000; /// Most vouchers one proposal set may carry. pub const MAX_PROPOSED_VOUCHERS: usize = 5_000; +/// Maximum proposal/window pair comparisons admitted before resemblance work. +/// The individual bounds permit a product that would otherwise make the +/// indexed resemblance pass quadratic in the two untrusted collections. +pub const MAX_PRESENCE_COMPARISONS: usize = 1_000_000; /// Most numbering declarations consumed for one presence request. pub const MAX_NUMBERING_DECLARATIONS: usize = MAX_PROPOSED_VOUCHERS; /// Aggregate UTF-8 bytes accepted while consuming numbering declarations. @@ -122,6 +126,8 @@ pub enum PresenceError { ProposalsEmpty, #[error("proposed voucher list exceeded its bound")] TooManyProposals, + #[error("proposal and book window comparison work exceeded its bound")] + ComparisonWorkTooLarge, #[error("voucher entry list was empty")] EntriesEmpty, #[error("voucher entry list exceeded its bound")] @@ -171,6 +177,7 @@ impl PresenceError { Self::WindowDoesNotCover => "presence_window_does_not_cover", Self::ProposalsEmpty => "presence_proposals_empty", Self::TooManyProposals => "presence_proposals_too_many", + Self::ComparisonWorkTooLarge => "presence_comparison_work_too_large", Self::EntriesEmpty => "presence_entries_empty", Self::TooManyEntries => "presence_entries_too_many", Self::NumberingMethodUndeclared => "presence_numbering_method_undeclared", @@ -945,6 +952,13 @@ impl<'a> PresenceRequest<'a> { if proposals.len() > MAX_PROPOSED_VOUCHERS { return Err(PresenceError::TooManyProposals); } + let comparisons = proposals + .len() + .checked_mul(window.vouchers().len()) + .ok_or(PresenceError::ComparisonWorkTooLarge)?; + if comparisons > MAX_PRESENCE_COMPARISONS { + return Err(PresenceError::ComparisonWorkTooLarge); + } for proposal in proposals { if !window.covers(proposal.date()) { return Err(PresenceError::WindowDoesNotCover); @@ -1320,11 +1334,11 @@ fn decide( )) .copied() == Some(1) - && number_matches.len() == 1 - && number_matches[0] != matches[0]; + && (number_matches.is_empty() + || (number_matches.len() == 1 && number_matches[0] != matches[0])); if number_selects_another { let mut touched = BTreeSet::from([matches[0]]); - touched.insert(number_matches[0]); + touched.extend(number_matches.iter().copied()); // Both sides go through one ranked constructor. Appending // and truncating could drop the number side wholesale when // the REMOTEID side alone filled the cap — hiding half of diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index c6157eb4..f80ce6b7 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1301,6 +1301,28 @@ fn an_empty_proposal_set_is_refused() { ); } +#[test] +fn aggregate_proposal_window_resemblance_work_is_refused() { + let books = (0..1_001) + .map(|index| BookRow::new( + Box::leak(format!("book-{index}").into_boxed_str()), + "20260812", + Box::leak(format!("N{index}").into_boxed_str()), + ).build()) + .collect::>(); + let window = BookWindow::observed( + "20260801", "20260831", WindowRead::Complete, + RemoteIdEvidence::Observed, books, + ).expect("window"); + let proposals = (0..1_001) + .map(|index| ProposalRow::new(index, "20260812", "N999999").build()) + .collect::>(); + let error = PresenceRequest::new( + &window, &catalog(), &numbering(NumberingMethod::Manual), &proposals, + ).expect_err("quadratic resemblance work must be bounded"); + assert_eq!(error, PresenceError::ComparisonWorkTooLarge); +} + #[test] fn a_stock_item_catalog_cannot_be_used_to_compare_parties() { let catalog = MasterCatalog::new(MasterClass::StockItem, LEDGERS).expect("catalog"); @@ -1773,15 +1795,11 @@ fn two_proposals_reaching_one_book_voucher_are_both_demoted() { &numbering(NumberingMethod::Manual), &proposals, ); - // Neither may be excluded from an import: only one voucher exists. - assert_eq!(report.totals().present, 0); - for entry in report.vouchers() { - assert_eq!(reason(entry), UndecidedReason::BookVoucherClaimedTwice); - assert_eq!( - entry.undecided().expect("undecided").candidates[0].book_key, - "book-1" - ); - } + // The first proposal's manual number is absent from the book, so its + // observed REMOTEID cannot override that contradictory identity signal. + assert_eq!(report.totals().present, 1); + assert_eq!(reason(&report.vouchers()[0]), UndecidedReason::IdentityConflict); + assert!(report.vouchers()[1].present_book_key().is_some()); } #[test] @@ -1840,6 +1858,23 @@ fn a_number_match_agreeing_with_the_remote_id_still_settles() { assert!(only(&report).present_book_key().is_some()); } +#[test] +fn a_remote_id_with_a_manual_number_absent_from_the_book_is_an_identity_conflict() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").remote_id("tally-1")]); + let proposals = [ProposalRow::new(0, "20260812", "AA9999") + .remote_id("tally-1") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::IdentityConflict); +} + // --- a key that was never read is not a key that found nothing ---------- #[test] From 634ff17c22663d5a1945caf5f53f64413755507f Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 01:35:54 +0530 Subject: [PATCH 61/91] style: format presence repair files --- .../bridge-tally-core/src/book_presence.rs | 7 ++- .../src/book_presence_tests.rs | 53 ++++++++++++------- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 472ee9f7..a5deb463 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -183,7 +183,9 @@ impl PresenceError { Self::NumberingMethodUndeclared => "presence_numbering_method_undeclared", Self::NumberingMethodConflict => "presence_numbering_method_conflict", Self::NumberingDeclarationsTooMany => "presence_numbering_declarations_too_many", - Self::NumberingDeclarationBytesTooLarge => "presence_numbering_declaration_bytes_too_large", + Self::NumberingDeclarationBytesTooLarge => { + "presence_numbering_declaration_bytes_too_large" + } Self::TextBlank => "presence_text_blank", Self::TextTooLong => "presence_text_too_long", Self::TextUnsafe => "presence_text_unsafe", @@ -313,7 +315,8 @@ impl BookVoucher { let voucher_number = input.voucher_number.map(validated_text).transpose()?; let remote_id = input.remote_id.map(validated_text).transpose()?; let party = input.party.map(validated_text).transpose()?; - let (magnitude, balanced, mut observed_ledgers, mut ledger_keys) = magnitude_of(input.entries)?; + let (magnitude, balanced, mut observed_ledgers, mut ledger_keys) = + magnitude_of(input.entries)?; if let Some(party) = party.as_deref() { observed_ledgers.insert(party.to_string()); ledger_keys.insert(comparison_key(party)); diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index f80ce6b7..5cc86f95 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -215,8 +215,10 @@ fn run( ); names.sort(); names.dedup(); - let complete_catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("complete catalog"); - let request = PresenceRequest::new(window, &complete_catalog, numbering, proposals).expect("request"); + let complete_catalog = + MasterCatalog::new(MasterClass::Ledger, &names).expect("complete catalog"); + let request = + PresenceRequest::new(window, &complete_catalog, numbering, proposals).expect("request"); assess(&request) } @@ -393,8 +395,7 @@ fn a_repeated_identical_declaration_is_accepted() { #[test] fn numbering_declarations_bound_duplicate_iterator_work() { - let entries = (0..=MAX_NUMBERING_DECLARATIONS) - .map(|_| ("Sales", NumberingMethod::Manual)); + let entries = (0..=MAX_NUMBERING_DECLARATIONS).map(|_| ("Sales", NumberingMethod::Manual)); assert_eq!( NumberingDeclaration::new(entries).expect_err("declaration count is bounded"), PresenceError::NumberingDeclarationsTooMany @@ -403,8 +404,7 @@ fn numbering_declarations_bound_duplicate_iterator_work() { #[test] fn numbering_declarations_bound_aggregate_bytes_while_consuming_duplicates() { - let entries = (0..) - .map(|_| ("X".repeat(MAX_TEXT_CHARS), NumberingMethod::Manual)); + let entries = (0..).map(|_| ("X".repeat(MAX_TEXT_CHARS), NumberingMethod::Manual)); assert_eq!( NumberingDeclaration::new(entries).expect_err("declaration bytes are bounded"), PresenceError::NumberingDeclarationBytesTooLarge @@ -413,8 +413,9 @@ fn numbering_declarations_bound_aggregate_bytes_while_consuming_duplicates() { #[test] fn request_refuses_a_window_ledger_missing_from_its_catalog() { - let window = window(&[BookRow::new("book-1", "20260812", "AA0118") - .rows(vec![["Uncatalogued Ledger", "0.00"]])]); + let window = + window(&[BookRow::new("book-1", "20260812", "AA0118") + .rows(vec![["Uncatalogued Ledger", "0.00"]])]); let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; assert_eq!( PresenceRequest::new( @@ -1304,22 +1305,33 @@ fn an_empty_proposal_set_is_refused() { #[test] fn aggregate_proposal_window_resemblance_work_is_refused() { let books = (0..1_001) - .map(|index| BookRow::new( - Box::leak(format!("book-{index}").into_boxed_str()), - "20260812", - Box::leak(format!("N{index}").into_boxed_str()), - ).build()) + .map(|index| { + BookRow::new( + Box::leak(format!("book-{index}").into_boxed_str()), + "20260812", + Box::leak(format!("N{index}").into_boxed_str()), + ) + .build() + }) .collect::>(); let window = BookWindow::observed( - "20260801", "20260831", WindowRead::Complete, - RemoteIdEvidence::Observed, books, - ).expect("window"); + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + books, + ) + .expect("window"); let proposals = (0..1_001) .map(|index| ProposalRow::new(index, "20260812", "N999999").build()) .collect::>(); let error = PresenceRequest::new( - &window, &catalog(), &numbering(NumberingMethod::Manual), &proposals, - ).expect_err("quadratic resemblance work must be bounded"); + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ) + .expect_err("quadratic resemblance work must be bounded"); assert_eq!(error, PresenceError::ComparisonWorkTooLarge); } @@ -1798,7 +1810,10 @@ fn two_proposals_reaching_one_book_voucher_are_both_demoted() { // The first proposal's manual number is absent from the book, so its // observed REMOTEID cannot override that contradictory identity signal. assert_eq!(report.totals().present, 1); - assert_eq!(reason(&report.vouchers()[0]), UndecidedReason::IdentityConflict); + assert_eq!( + reason(&report.vouchers()[0]), + UndecidedReason::IdentityConflict + ); assert!(report.vouchers()[1].present_book_key().is_some()); } From 0e55c62160f247dd5096ef5e8c4c5499894a0a08 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 01:37:25 +0530 Subject: [PATCH 62/91] chore: reseal compatibility surface --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 9f476d3f..117b5b83 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": "6e37a277baf4d1a01747a6fb88a389784dbaf119d6b14f93df6882e8574560cd", + "compatibility_surface_sha256": "7c8b8b950f79e3b0ef9659916029ecfaef18e0b9e422d6082d2fb40ebba1370e", "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 58dd81f9..815a511f 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "9efbf7e48c37c8bae9ae00314cd8e0c371e28221f00f6671bcbc7d0b98067364" + "sha256": "d09b0d6b8a7cc56cf31a17fe238599a3904522c3c7adb5ffff5e1191f9e91288" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "6e37a277baf4d1a01747a6fb88a389784dbaf119d6b14f93df6882e8574560cd" + "manifest_sha256": "7c8b8b950f79e3b0ef9659916029ecfaef18e0b9e422d6082d2fb40ebba1370e" } \ No newline at end of file From e21576b8ba3e9277029340815e7dc98af477b1c9 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 01:43:00 +0530 Subject: [PATCH 63/91] fix(presence): bound indexed resemblance work --- .../bridge-tally-core/src/book_presence.rs | 44 ++++++++++++++++++- .../src/book_presence_tests.rs | 44 +++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index a5deb463..04d3078b 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -36,6 +36,9 @@ pub const MAX_PROPOSED_VOUCHERS: usize = 5_000; /// The individual bounds permit a product that would otherwise make the /// indexed resemblance pass quadratic in the two untrusted collections. pub const MAX_PRESENCE_COMPARISONS: usize = 1_000_000; +/// Aggregate indexed resemblance work units, including posting-list walks and +/// the party-key checks performed for every pooled voucher. +pub const MAX_PRESENCE_WORK_UNITS: usize = 5_000_000; /// Most numbering declarations consumed for one presence request. pub const MAX_NUMBERING_DECLARATIONS: usize = MAX_PROPOSED_VOUCHERS; /// Aggregate UTF-8 bytes accepted while consuming numbering declarations. @@ -971,6 +974,10 @@ impl<'a> PresenceRequest<'a> { } } let party_bindings = bind_parties(catalog, proposals)?; + let index = WindowIndex::build(window); + if resemblance_work_units(proposals, &party_bindings, &index)? > MAX_PRESENCE_WORK_UNITS { + return Err(PresenceError::ComparisonWorkTooLarge); + } Ok(Self { window, numbering, @@ -1140,6 +1147,42 @@ impl<'a> WindowIndex<'a> { } } +/// Conservatively prices the indexed resemblance pass. The bound includes +/// each proposal's date posting list, every party-key posting list, the pooled +/// voucher checks, and the per-pooled-voucher `any` over party keys. It applies +/// even when an identity path settles, because those paths retain the full +/// resemblance set for observations. +fn resemblance_work_units( + proposals: &[ProposedVoucher], + parties: &[PartyResolution], + index: &WindowIndex<'_>, +) -> Result { + let mut total = 0usize; + for (proposal, party) in proposals.iter().zip(parties) { + let date_posts = index.by_date.get(proposal.date()).map_or(0, Vec::len); + let party_posts = party.compare_keys.iter().try_fold(0usize, |sum, key| { + sum.checked_add(index.by_ledger.get(key.as_str()).map_or(0, Vec::len)) + .ok_or(PresenceError::ComparisonWorkTooLarge) + })?; + let pool_upper = date_posts + .checked_add(party_posts) + .ok_or(PresenceError::ComparisonWorkTooLarge)?; + let party_checks = pool_upper + .checked_mul(party.compare_keys.len()) + .ok_or(PresenceError::ComparisonWorkTooLarge)?; + let units = 1usize + .checked_add(date_posts) + .and_then(|n| n.checked_add(party_posts)) + .and_then(|n| n.checked_add(pool_upper)) + .and_then(|n| n.checked_add(party_checks)) + .ok_or(PresenceError::ComparisonWorkTooLarge)?; + total = total + .checked_add(units) + .ok_or(PresenceError::ComparisonWorkTooLarge)?; + } + Ok(total) +} + /// Decides every proposal against the window. /// /// `Present` requires identity unique on both sides. `Absent` requires that no @@ -1329,7 +1372,6 @@ fn decide( // another is two identity signals disagreeing, and ranking one // of them is the move this contract refuses everywhere else. let number_selects_another = method == NumberingMethod::Manual - && type_observed && proposal_number_counts .get(&( proposal.type_key.as_str(), diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 5cc86f95..e5a47730 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -411,6 +411,32 @@ fn numbering_declarations_bound_aggregate_bytes_while_consuming_duplicates() { ); } +#[test] +fn admitted_indexed_work_boundary_is_accepted() { + let rows = (0..500) + .map(|i| { + BookRow::new( + Box::leak(format!("book-{i}").into_boxed_str()), + "20260812", + Box::leak(format!("N{i}").into_boxed_str()), + ) + }) + .collect::>(); + let proposals = (0..500) + .map(|i| { + ProposalRow::new(i, "20260812", Box::leak(format!("P{i}").into_boxed_str())).build() + }) + .collect::>(); + let observed = window(&rows); + assert!(PresenceRequest::new( + &observed, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ) + .is_ok()); +} + #[test] fn request_refuses_a_window_ledger_missing_from_its_catalog() { let window = @@ -2107,6 +2133,24 @@ fn a_remote_id_and_a_number_selecting_different_vouchers_do_not_settle() { .any(|c| c.book_key == "book-2" && c.rule == CandidateRule::SharedVoucherNumber)); } +#[test] +fn a_remote_id_with_an_absent_manual_number_on_an_unobserved_type_is_a_conflict() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").remote_id("tally-1")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999") + .voucher_type("Other") + .remote_id("tally-1") + .build()]; + let numbering = NumberingDeclaration::new([ + ("Sales", NumberingMethod::Manual), + ("Other", NumberingMethod::Manual), + ]) + .expect("numbering"); + let report = run(&window, &catalog(), &numbering, &proposals); + let entry = only(&report); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::IdentityConflict); +} + #[test] fn a_nonunique_proposal_number_cannot_contradict_a_unique_remote_id() { let window = window(&[ From 9ef08d1ac1586d266d55bbbde07864d68cb9b0c6 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 01:43:27 +0530 Subject: [PATCH 64/91] chore: reseal compatibility surface after work bound --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 117b5b83..51211af4 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": "7c8b8b950f79e3b0ef9659916029ecfaef18e0b9e422d6082d2fb40ebba1370e", + "compatibility_surface_sha256": "d289c953a618b669f84a783d8fe5e297bfef244da052c145c6edb74b01c39b95", "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 815a511f..62d3021e 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "d09b0d6b8a7cc56cf31a17fe238599a3904522c3c7adb5ffff5e1191f9e91288" + "sha256": "cb187c835ea0413c46a5ac0ab0e44c895a9743feca5bb0ed9159bd81fc11c247" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "7c8b8b950f79e3b0ef9659916029ecfaef18e0b9e422d6082d2fb40ebba1370e" + "manifest_sha256": "d289c953a618b669f84a783d8fe5e297bfef244da052c145c6edb74b01c39b95" } \ No newline at end of file From db36f57f1ceec9b096f6401d86ac7dde57952c77 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 01:58:37 +0530 Subject: [PATCH 65/91] Refuse contradictory observed remote identities --- .../bridge-tally-core/src/book_presence.rs | 21 ++++++++++- .../src/book_presence_tests.rs | 35 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 04d3078b..a8b2287e 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -1425,6 +1425,25 @@ fn decide( } } + // A supplied REMOTEID is observed evidence even when no book row carries it. + // Do not let a coincident manual number settle a row whose matched book voucher + // lacks that supplied identity; NotRead remains withheld by `skipped_evidence`. + if window.remote_id_evidence() == RemoteIdEvidence::Observed + && proposal.remote_id.is_some() + && proposal.remote_id.as_deref().map_or(false, |key| index.by_remote_id.get(key).map_or(true, Vec::is_empty)) + && method == NumberingMethod::Manual + && number_matches.len() == 1 + && window.vouchers[number_matches[0]].remote_id.is_none() + { + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::IdentityConflict, + candidates_from(window, &number_matches, CandidateRule::SharedVoucherNumber), + )), + with_resemblances(number_matches.iter().copied().collect()), + ); + } + // Rule two: a voucher number is identity only where the numbering method // preserves it (§9.8), and only when it is unique on both sides. // @@ -1478,7 +1497,7 @@ fn decide( // settled in the number's favour — the same rule ADR 0016 // applies to an identifier contradicting an exact name. let contradicted = match (proposal.remote_id.as_deref(), matched.remote_id.as_deref()) { - (Some(proposed), Some(observed)) => proposed != observed, + (Some(proposed), observed) => observed != Some(proposed), _ => false, }; if remote_id_unverifiable { diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index e5a47730..62678120 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1361,6 +1361,29 @@ fn aggregate_proposal_window_resemblance_work_is_refused() { assert_eq!(error, PresenceError::ComparisonWorkTooLarge); } +#[test] +fn weighted_party_fanout_is_bounded_below_the_pair_product_limit() { + let names = (0..25) + .map(|i| Box::leak(format!("Party Key {i}").into_boxed_str()) as &'static str) + .collect::>(); + let rows = (0..999) + .map(|i| BookRow::new(Box::leak(format!("book-{i}").into_boxed_str()), "20260812", "N") + .rows(names.iter().map(|name| [*name, "0.00"]).collect()) + .party_field(names[0]) + .build()) + .collect::>(); + let observed = BookWindow::observed("20260801", "20260831", WindowRead::Complete, RemoteIdEvidence::Observed, rows).expect("window"); + let proposals = (0..500).map(|i| ProposalRow::new(i, "20260812", "P").party("Party").build()).collect::>(); + assert!(proposals.len() * observed.vouchers().len() < MAX_PRESENCE_COMPARISONS); + let keys = names.iter().map(|name| comparison_key(name)).collect::>(); + assert_eq!(keys.len(), 25, "fixture must retain every party fanout key"); + let parties = vec![PartyResolution { outcome: PartyOutcome::Ambiguous { reason: "test".into(), candidate_count: 25 }, compare_keys: keys, incomplete: false }; proposals.len()]; + let index = WindowIndex::build(&observed); + let work = resemblance_work_units(&proposals, &parties, &index).expect("count"); + assert!(work > MAX_PRESENCE_WORK_UNITS, "weighted fanout must exceed admission bound"); + assert_eq!(PresenceRequest::new(&observed, &catalog_of(&names), &numbering(NumberingMethod::Manual), &proposals).expect_err("real admission refuses"), PresenceError::ComparisonWorkTooLarge); +} + #[test] fn a_stock_item_catalog_cannot_be_used_to_compare_parties() { let catalog = MasterCatalog::new(MasterClass::StockItem, LEDGERS).expect("catalog"); @@ -1883,6 +1906,18 @@ fn a_number_match_contradicted_by_a_different_remote_id_does_not_settle() { assert_eq!(reason(entry), UndecidedReason::IdentityConflict); } +#[test] +fn a_number_match_without_the_proposed_observed_remote_id_does_not_settle() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .remote_id("previous-id") + .build()]; + let report = run(&window, &catalog(), &numbering(NumberingMethod::Manual), &proposals); + let entry = only(&report); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::IdentityConflict); +} + #[test] fn a_number_match_agreeing_with_the_remote_id_still_settles() { let window = window(&[BookRow::new("book-1", "20260812", "AA0118").remote_id("tally-1")]); From bb6f2634edcccc722d1283cb63168f2c97eacaec Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 02:02:50 +0530 Subject: [PATCH 66/91] fix(presence): bound weighted party resolution --- .../bridge-tally-core/src/book_presence.rs | 19 ------------------- .../src/book_presence_tests.rs | 16 ++++++++++------ 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index a8b2287e..e0dcf6ff 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -1425,25 +1425,6 @@ fn decide( } } - // A supplied REMOTEID is observed evidence even when no book row carries it. - // Do not let a coincident manual number settle a row whose matched book voucher - // lacks that supplied identity; NotRead remains withheld by `skipped_evidence`. - if window.remote_id_evidence() == RemoteIdEvidence::Observed - && proposal.remote_id.is_some() - && proposal.remote_id.as_deref().map_or(false, |key| index.by_remote_id.get(key).map_or(true, Vec::is_empty)) - && method == NumberingMethod::Manual - && number_matches.len() == 1 - && window.vouchers[number_matches[0]].remote_id.is_none() - { - return shell( - PresenceStatus::PossiblyPresent(undecided( - UndecidedReason::IdentityConflict, - candidates_from(window, &number_matches, CandidateRule::SharedVoucherNumber), - )), - with_resemblances(number_matches.iter().copied().collect()), - ); - } - // Rule two: a voucher number is identity only where the numbering method // preserves it (§9.8), and only when it is unique on both sides. // diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 62678120..72269a6f 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1375,13 +1375,17 @@ fn weighted_party_fanout_is_bounded_below_the_pair_product_limit() { let observed = BookWindow::observed("20260801", "20260831", WindowRead::Complete, RemoteIdEvidence::Observed, rows).expect("window"); let proposals = (0..500).map(|i| ProposalRow::new(i, "20260812", "P").party("Party").build()).collect::>(); assert!(proposals.len() * observed.vouchers().len() < MAX_PRESENCE_COMPARISONS); - let keys = names.iter().map(|name| comparison_key(name)).collect::>(); - assert_eq!(keys.len(), 25, "fixture must retain every party fanout key"); - let parties = vec![PartyResolution { outcome: PartyOutcome::Ambiguous { reason: "test".into(), candidate_count: 25 }, compare_keys: keys, incomplete: false }; proposals.len()]; + let catalog = catalog_of(&names); + let parties = bind_parties(&catalog, &proposals).expect("actual party binding"); + assert_eq!(parties[0].compare_keys.len(), 25, "fixture must retain every party fanout key"); let index = WindowIndex::build(&observed); - let work = resemblance_work_units(&proposals, &parties, &index).expect("count"); - assert!(work > MAX_PRESENCE_WORK_UNITS, "weighted fanout must exceed admission bound"); - assert_eq!(PresenceRequest::new(&observed, &catalog_of(&names), &numbering(NumberingMethod::Manual), &proposals).expect_err("real admission refuses"), PresenceError::ComparisonWorkTooLarge); + let per_proposal = resemblance_work_units(&proposals[..1], &parties[..1], &index).expect("unit cost"); + let admitted_count = MAX_PRESENCE_WORK_UNITS / per_proposal; + assert!(admitted_count > 0 && admitted_count < proposals.len()); + assert!(resemblance_work_units(&proposals[..admitted_count], &parties[..admitted_count], &index).expect("admitted count") <= MAX_PRESENCE_WORK_UNITS); + assert!(resemblance_work_units(&proposals[..admitted_count + 1], &parties[..admitted_count + 1], &index).expect("refused count") > MAX_PRESENCE_WORK_UNITS); + assert!(PresenceRequest::new(&observed, &catalog, &numbering(NumberingMethod::Manual), &proposals[..admitted_count]).is_ok()); + assert_eq!(PresenceRequest::new(&observed, &catalog, &numbering(NumberingMethod::Manual), &proposals[..admitted_count + 1]).expect_err("real admission refuses"), PresenceError::ComparisonWorkTooLarge); } #[test] From f846273d22c86f8f894326619f743691e02c7a89 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 02:06:31 +0530 Subject: [PATCH 67/91] chore: reseal presence compatibility surface --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../src/book_presence_tests.rs | 78 ++++++++++++++++--- 3 files changed, 70 insertions(+), 14 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 51211af4..6c4a7dd5 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": "d289c953a618b669f84a783d8fe5e297bfef244da052c145c6edb74b01c39b95", + "compatibility_surface_sha256": "504546680152890b48ca7e98d66f5e6436f09e3c81f18d3a0c2270f4cd445a1e", "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 62d3021e..19c03f50 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "cb187c835ea0413c46a5ac0ab0e44c895a9743feca5bb0ed9159bd81fc11c247" + "sha256": "d3beaf91ddd22ccdea1b01eff998dfd143a335f2f1c66112943afcc03c2976c0" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "d289c953a618b669f84a783d8fe5e297bfef244da052c145c6edb74b01c39b95" + "manifest_sha256": "504546680152890b48ca7e98d66f5e6436f09e3c81f18d3a0c2270f4cd445a1e" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 72269a6f..5f2577f6 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1367,25 +1367,76 @@ fn weighted_party_fanout_is_bounded_below_the_pair_product_limit() { .map(|i| Box::leak(format!("Party Key {i}").into_boxed_str()) as &'static str) .collect::>(); let rows = (0..999) - .map(|i| BookRow::new(Box::leak(format!("book-{i}").into_boxed_str()), "20260812", "N") + .map(|i| { + BookRow::new( + Box::leak(format!("book-{i}").into_boxed_str()), + "20260812", + "N", + ) .rows(names.iter().map(|name| [*name, "0.00"]).collect()) .party_field(names[0]) - .build()) + .build() + }) + .collect::>(); + let observed = BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + rows, + ) + .expect("window"); + let proposals = (0..500) + .map(|i| ProposalRow::new(i, "20260812", "P").party("Party").build()) .collect::>(); - let observed = BookWindow::observed("20260801", "20260831", WindowRead::Complete, RemoteIdEvidence::Observed, rows).expect("window"); - let proposals = (0..500).map(|i| ProposalRow::new(i, "20260812", "P").party("Party").build()).collect::>(); assert!(proposals.len() * observed.vouchers().len() < MAX_PRESENCE_COMPARISONS); let catalog = catalog_of(&names); let parties = bind_parties(&catalog, &proposals).expect("actual party binding"); - assert_eq!(parties[0].compare_keys.len(), 25, "fixture must retain every party fanout key"); + assert_eq!( + parties[0].compare_keys.len(), + 25, + "fixture must retain every party fanout key" + ); let index = WindowIndex::build(&observed); - let per_proposal = resemblance_work_units(&proposals[..1], &parties[..1], &index).expect("unit cost"); + let per_proposal = + resemblance_work_units(&proposals[..1], &parties[..1], &index).expect("unit cost"); let admitted_count = MAX_PRESENCE_WORK_UNITS / per_proposal; assert!(admitted_count > 0 && admitted_count < proposals.len()); - assert!(resemblance_work_units(&proposals[..admitted_count], &parties[..admitted_count], &index).expect("admitted count") <= MAX_PRESENCE_WORK_UNITS); - assert!(resemblance_work_units(&proposals[..admitted_count + 1], &parties[..admitted_count + 1], &index).expect("refused count") > MAX_PRESENCE_WORK_UNITS); - assert!(PresenceRequest::new(&observed, &catalog, &numbering(NumberingMethod::Manual), &proposals[..admitted_count]).is_ok()); - assert_eq!(PresenceRequest::new(&observed, &catalog, &numbering(NumberingMethod::Manual), &proposals[..admitted_count + 1]).expect_err("real admission refuses"), PresenceError::ComparisonWorkTooLarge); + assert!( + resemblance_work_units( + &proposals[..admitted_count], + &parties[..admitted_count], + &index + ) + .expect("admitted count") + <= MAX_PRESENCE_WORK_UNITS + ); + assert!( + resemblance_work_units( + &proposals[..admitted_count + 1], + &parties[..admitted_count + 1], + &index + ) + .expect("refused count") + > MAX_PRESENCE_WORK_UNITS + ); + assert!(PresenceRequest::new( + &observed, + &catalog, + &numbering(NumberingMethod::Manual), + &proposals[..admitted_count] + ) + .is_ok()); + assert_eq!( + PresenceRequest::new( + &observed, + &catalog, + &numbering(NumberingMethod::Manual), + &proposals[..admitted_count + 1] + ) + .expect_err("real admission refuses"), + PresenceError::ComparisonWorkTooLarge + ); } #[test] @@ -1916,7 +1967,12 @@ fn a_number_match_without_the_proposed_observed_remote_id_does_not_settle() { let proposals = [ProposalRow::new(0, "20260812", "AA0118") .remote_id("previous-id") .build()]; - let report = run(&window, &catalog(), &numbering(NumberingMethod::Manual), &proposals); + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); let entry = only(&report); assert!(entry.present_book_key().is_none()); assert_eq!(reason(entry), UndecidedReason::IdentityConflict); From ca673ee40b49c1f3c8cadda7d64c4a17bed9a321 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 02:09:00 +0530 Subject: [PATCH 68/91] docs: complete presence candidate rule contract --- docs/adr/0017-voucher-presence-authority.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index f3a51842..8cb5c6b9 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -162,6 +162,12 @@ Every verdict is therefore explicitly scoped to the window the report carries. book". A voucher keyed in September against an August window is not visible, and widening the window is the caller's decision, made in the open. +Admission is also bounded before comparison: a request above **1,000,000** +proposal/window pairs, or above **5,000,000** aggregate indexed resemblance +work units, is refused as `ComparisonWorkTooLarge`. The second limit counts +posting-list walks and party-key checks, so it still applies when the pair count +is below one million but one party resolves to many candidate keys. + ### 3. The numbering method is declared, and its absence is an error The decisive power of a voucher number depends entirely on the voucher type's @@ -192,8 +198,9 @@ Per proposed voucher, exactly one of: | `Absent` | No rule produced any candidate, in a window proven to cover it | including this voucher in the import | `PossiblyPresent` carries candidates labelled with the **rule that surfaced -each** — `SharedVoucherNumber`, `SameDatePartyAmount`, `SamePartyAmount`, -`SameDateAmount`, `SameDateParty` — ordered by rule and then by the book +each** — `SharedRemoteId`, `SharedVoucherNumber`, +`SameDatePartyAmount`, `SamePartyAmount`, `SameDateAmount`, `SameDateParty` — +ordered by rule and then by the book voucher's own ordering. **No candidate is marked best, likely or preferred, and no score is emitted anywhere.** From e61354c36580bebb6a974357995b0bd2ed1d1868 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 02:40:08 +0530 Subject: [PATCH 69/91] fix(presence): bound raw book observations --- docs/adr/0017-voucher-presence-authority.md | 10 ++- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 8 +- .../bridge-tally-core/src/book_presence.rs | 53 +++++++++++- .../src/book_presence_tests.rs | 81 +++++++++++++++++++ src-tauri/src/agent_presence.rs | 71 ++++++++-------- src-tauri/src/agent_presence_tests.rs | 8 +- 7 files changed, 188 insertions(+), 45 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 8cb5c6b9..9c69efdb 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -162,7 +162,9 @@ Every verdict is therefore explicitly scoped to the window the report carries. book". A voucher keyed in September against an August window is not visible, and widening the window is the caller's decision, made in the open. -Admission is also bounded before comparison: a request above **1,000,000** +Raw book observations are admitted before decimal parsing, cloning, or folding: +**100,000** total entries and **4 MiB** of entry ledger-and-amount bytes are +the aggregate limits. Admission is also bounded before comparison: a request above **1,000,000** proposal/window pairs, or above **5,000,000** aggregate indexed resemblance work units, is refused as `ComparisonWorkTooLarge`. The second limit counts posting-list walks and party-key checks, so it still applies when the pair count @@ -240,7 +242,8 @@ leaves open: - **Two identity signals that disagree are reported, not ranked.** Both lookups are resolved *before* either settles, so a `REMOTEID` selecting one voucher while the number selects another is `IdentityConflict` — as is a - number matching uniquely while the two sides carry different `REMOTEID`s. + number matching uniquely while the two sides carry different `REMOTEID`s, or + while the proposal supplies one and the book voucher has none. Settling on whichever basis happened to be evaluated first would rank them, which is the move ADR 0016 refuses when an identifier contradicts an exact name. @@ -266,7 +269,8 @@ a `Present`, and a `Present` tells a caller the invoice is already filed. Two distinct invoices numbered `aa-0118` and `AA-0118` would each have suppressed the other. -So a number is compared on NFC and **outer whitespace trimming only**. Outer +So a number is compared with **outer whitespace trimming only**; its Unicode +form is preserved. Outer padding is a transport artefact; internal whitespace, case and punctuation are **content** until voucher-number evidence measures an equivalence. Treating them so fails toward the noisy direction — a non-match withholds a decisive diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 6c4a7dd5..0f0dd6a5 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": "504546680152890b48ca7e98d66f5e6436f09e3c81f18d3a0c2270f4cd445a1e", + "compatibility_surface_sha256": "188ecd6b1b249cbbfb715eb47be8cbea9b9f040a688ff13927271295c57206db", "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 19c03f50..cc233335 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "d3beaf91ddd22ccdea1b01eff998dfd143a335f2f1c66112943afcc03c2976c0" + "sha256": "c1b573d39cf458cb8b31964751ce4e411c33a1a72c7ee1f33e881342e059b618" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -343,11 +343,11 @@ }, { "path": "src-tauri/src/agent_presence.rs", - "sha256": "deb87916a9ea8135170c28555b4bbf5e1b7649c1a0c47e1d0ab35951161ca37e" + "sha256": "359fee34db9b9da4f65b177130d96e3437bb5d841c0d0c7efafb66247693e31c" }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "9120b76ccca4164695eee5ece22a8b598caa39568799ec48c8f19e86a5312fa8" + "sha256": "8fb62eaa288cff6b45534132da81f6e4ecb309f1b78b6d11e5a01b8bf2bab5c8" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "504546680152890b48ca7e98d66f5e6436f09e3c81f18d3a0c2270f4cd445a1e" + "manifest_sha256": "188ecd6b1b249cbbfb715eb47be8cbea9b9f040a688ff13927271295c57206db" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index e0dcf6ff..cbba6707 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -45,6 +45,10 @@ pub const MAX_NUMBERING_DECLARATIONS: usize = MAX_PROPOSED_VOUCHERS; pub const MAX_NUMBERING_DECLARATION_BYTES: usize = 1_048_576; /// Most ledger entries one voucher may carry. pub const MAX_ENTRIES_PER_VOUCHER: usize = 2_000; +/// Aggregate raw entries admitted before parsing, cloning, or folding them. +pub const MAX_WINDOW_RAW_ENTRY_WORK: usize = 100_000; +/// Aggregate raw entry bytes admitted before parsing, cloning, or folding them. +pub const MAX_WINDOW_RAW_ENTRY_BYTES: usize = 4 * 1024 * 1024; /// Most distinct voucher-to-ledger memberships retained across one window. /// /// `WindowIndex` must retain every membership once more to find party @@ -108,6 +112,10 @@ pub enum PresenceError { WindowTooLarge, #[error("book window ledger memberships exceeded their aggregate bound")] WindowLedgerMembershipsTooMany, + #[error("book window raw entries exceeded their aggregate bound")] + WindowRawEntryWorkTooLarge, + #[error("book window raw entry bytes exceeded their aggregate bound")] + WindowRawEntryBytesTooLarge, #[error("book window ledger keys exceeded their aggregate byte bound")] WindowLedgerKeyBytesTooLarge, #[error("book window carried a voucher dated outside its own range")] @@ -172,6 +180,8 @@ impl PresenceError { Self::WindowRangeInvalid => "presence_window_range_invalid", Self::WindowTooLarge => "presence_window_too_large", Self::WindowLedgerMembershipsTooMany => "presence_window_ledger_memberships_too_many", + Self::WindowRawEntryWorkTooLarge => "presence_window_raw_entry_work_too_large", + Self::WindowRawEntryBytesTooLarge => "presence_window_raw_entry_bytes_too_large", Self::WindowLedgerKeyBytesTooLarge => "presence_window_ledger_key_bytes_too_large", Self::WindowVoucherOutsideRange => "presence_window_voucher_outside_range", Self::WindowDuplicateVoucherKey => "presence_window_duplicate_voucher_key", @@ -307,7 +317,7 @@ pub struct BookVoucher { } impl BookVoucher { - pub fn observed(input: ObservedVoucher<'_>) -> Result { + pub(crate) fn observed(input: ObservedVoucher<'_>) -> Result { let key = validated_text(input.key)?; if key.chars().count() > MAX_BOOK_KEY_CHARS { return Err(PresenceError::VoucherKeyTooLong); @@ -448,7 +458,46 @@ pub struct BookWindow { } impl BookWindow { - pub fn observed( + /// Admits raw observations in aggregate before the per-voucher conversion + /// performs decimal parsing, string cloning, and comparison-key folding. + pub fn from_observations<'a>( + from: &str, + to: &str, + read: WindowRead, + remote_id_evidence: RemoteIdEvidence, + observations: impl IntoIterator>, + ) -> Result { + let mut raw_entries = 0usize; + let mut raw_bytes = 0usize; + let mut vouchers = Vec::new(); + for observation in observations { + raw_entries = raw_entries + .checked_add(observation.entries.len()) + .ok_or(PresenceError::WindowRawEntryWorkTooLarge)?; + if raw_entries > MAX_WINDOW_RAW_ENTRY_WORK { + return Err(PresenceError::WindowRawEntryWorkTooLarge); + } + let observation_bytes = observation + .entries + .iter() + .try_fold(0usize, |total, entry| { + total + .checked_add(entry.ledger.len())? + .checked_add(entry.amount.len()) + }) + .ok_or(PresenceError::WindowRawEntryBytesTooLarge)?; + raw_bytes = raw_bytes + .checked_add(observation_bytes) + .ok_or(PresenceError::WindowRawEntryBytesTooLarge)?; + if raw_bytes > MAX_WINDOW_RAW_ENTRY_BYTES { + return Err(PresenceError::WindowRawEntryBytesTooLarge); + } + vouchers.push(BookVoucher::observed(observation)?); + } + Self::observed(from, to, read, remote_id_evidence, vouchers) + } + + pub(crate) fn observed( from: &str, to: &str, read: WindowRead, diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 5f2577f6..87253cce 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1544,6 +1544,87 @@ fn a_window_bounds_aggregate_ledger_memberships_before_indexing() { ); } +#[test] +fn raw_observations_are_bounded_before_voucher_conversion() { + let entry = ObservedEntry { + ledger: "Cash", + amount: "1.00", + }; + let rows = vec![entry; MAX_WINDOW_RAW_ENTRY_WORK + 1]; + assert_eq!( + BookWindow::from_observations( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + [ObservedVoucher { + key: "book-1", + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: &rows, + cancelled: false, + optional: false + }], + ) + .expect_err("raw entries must be refused before parsing"), + PresenceError::WindowRawEntryWorkTooLarge + ); + let long = "x".repeat(MAX_WINDOW_RAW_ENTRY_BYTES + 1); + let oversized = [ObservedEntry { + ledger: &long, + amount: "1.00", + }]; + assert_eq!( + BookWindow::from_observations( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + [ObservedVoucher { + key: "book-2", + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: &oversized, + cancelled: false, + optional: false + }], + ) + .expect_err("raw bytes must be refused before cloning"), + PresenceError::WindowRawEntryBytesTooLarge + ); + let admitted_entries = (0..(MAX_WINDOW_RAW_ENTRY_WORK / MAX_ENTRIES_PER_VOUCHER)) + .map(|_| vec![entry; MAX_ENTRIES_PER_VOUCHER]) + .collect::>(); + let admitted = admitted_entries + .iter() + .enumerate() + .map(|(position, entries)| ObservedVoucher { + key: Box::leak(format!("admitted-{position}").into_boxed_str()), + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries, + cancelled: false, + optional: false, + }); + assert!(BookWindow::from_observations( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + admitted + ) + .is_ok()); +} + #[test] fn a_window_bounds_aggregate_ledger_key_bytes_before_indexing() { let ledgers = (0..(MAX_WINDOW_LEDGER_KEY_BYTES / MAX_TEXT_CHARS + 1)) diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs index a66fdb3b..53d71b07 100644 --- a/src-tauri/src/agent_presence.rs +++ b/src-tauri/src/agent_presence.rs @@ -9,9 +9,9 @@ use super::*; use std::collections::BTreeSet; use bridge_tally_core::book_presence::{ - self, BookVoucher, BookWindow, NumberingDeclaration, NumberingMethod, ObservedEntry, - ObservedVoucher, PresenceError, PresenceReport, PresenceRequest, ProposedVoucher, - ProposedVoucherInput, RemoteIdEvidence, WindowRead, + self, BookWindow, NumberingDeclaration, NumberingMethod, ObservedEntry, ObservedVoucher, + PresenceError, PresenceReport, PresenceRequest, ProposedVoucher, ProposedVoucherInput, + RemoteIdEvidence, WindowRead, }; use bridge_tally_core::master_binding::{MasterCatalog, MasterClass, SourceEntity}; @@ -202,18 +202,11 @@ impl Server { return Err("ledger_snapshot_drifted".to_string().into()); } - let observed = rows - .iter() - .map(book_voucher) - .collect::, _>>() - .map_err(presence_code)?; // The qualified `vouchers` profile does not FETCH REMOTEID, so an // absent value here means "never read", not "the voucher has // none". Declaring that keeps a proposal whose own REMOTEID was // never compared out of `absent`. - let window = - BookWindow::observed(&from, &to, read, RemoteIdEvidence::NotRead, observed) - .map_err(presence_code)?; + let window = book_window(&from, &to, read, &rows).map_err(presence_code)?; let request = PresenceRequest::new(&window, &catalog, &numbering, &proposals) .map_err(presence_code)?; let report = book_presence::assess(&request); @@ -261,30 +254,44 @@ fn presence_code(error: PresenceError) -> ToolFailure { /// observed book voucher. `REMOTEID` is deliberately not read here: the /// `vouchers` profile does not fetch it, and inventing an absent column would /// be worse than reporting that it was never observed. -fn book_voucher(row: &Value) -> Result { - let entries = row["amounts"] - .as_array() - .map(Vec::as_slice) - .unwrap_or_default() +fn book_window( + from: &str, + to: &str, + read: WindowRead, + rows: &[Value], +) -> Result { + let entries = rows .iter() - .map(|entry| ObservedEntry { - ledger: entry["ledger"].as_str().unwrap_or_default(), - amount: entry["amount"].as_str().unwrap_or_default(), + .map(|row| { + row["amounts"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .map(|entry| ObservedEntry { + ledger: entry["ledger"].as_str().unwrap_or_default(), + amount: entry["amount"].as_str().unwrap_or_default(), + }) + .collect::>() }) .collect::>(); - BookVoucher::observed(ObservedVoucher { - // The GUID is the identity the window read already proved belongs to - // this company, and the same field this tool's sibling already emits. - key: row["guid"].as_str().unwrap_or_default(), - date: row["date"].as_str().unwrap_or_default(), - voucher_type: row["voucher_type"].as_str().unwrap_or_default(), - voucher_number: row["voucher_number"].as_str(), - remote_id: None, - party: row["party"].as_str(), - entries: &entries, - cancelled: row["cancelled"].as_bool().unwrap_or_default(), - optional: row["optional"].as_bool().unwrap_or_default(), - }) + let observations = rows + .iter() + .zip(&entries) + .map(|(row, entries)| ObservedVoucher { + // The GUID is the identity the window read already proved belongs to + // this company, and the same field this tool's sibling already emits. + key: row["guid"].as_str().unwrap_or_default(), + date: row["date"].as_str().unwrap_or_default(), + voucher_type: row["voucher_type"].as_str().unwrap_or_default(), + voucher_number: row["voucher_number"].as_str(), + remote_id: None, + party: row["party"].as_str(), + entries, + cancelled: row["cancelled"].as_bool().unwrap_or_default(), + optional: row["optional"].as_bool().unwrap_or_default(), + }); + BookWindow::from_observations(from, to, read, RemoteIdEvidence::NotRead, observations) } fn parse_numbering(args: &Value) -> Result { diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index b97823c4..697a43a6 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -243,12 +243,12 @@ fn a_caller_limited_presence_page_includes_its_resume_cursor() { }) .expect("proposal") }); - let window = BookWindow::observed( + let window = BookWindow::from_observations( "20260901", "20260930", WindowRead::Complete, RemoteIdEvidence::NotRead, - vec![], + std::iter::empty(), ) .expect("complete empty window"); let catalogue = vec!["Cash".to_string(), "Sales".to_string()]; @@ -444,7 +444,9 @@ fn a_window_row_becomes_a_book_voucher_without_inventing_a_remote_id() { {"ledger": "WR2 Sales", "amount": "12.50"}, ], }); - let voucher = book_voucher(&row).expect("book voucher"); + let window = + book_window("20260901", "20260901", WindowRead::Complete, &[row]).expect("book window"); + let voucher = &window.vouchers()[0]; assert_eq!(voucher.key(), format!("{CAPTURED_GUID}-00000001")); assert_eq!(voucher.magnitude().as_str(), "12.5"); assert!(voucher.balanced()); From 91e9e98ba966881eb9c9456914ab3ec58ddbd4bd Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 02:43:38 +0530 Subject: [PATCH 70/91] fix(presence): share raw observation admission --- .../bridge-tally-core/src/book_presence.rs | 72 +++++++++++++------ src-tauri/src/agent_presence.rs | 25 +++++-- 2 files changed, 67 insertions(+), 30 deletions(-) diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index cbba6707..08b5884b 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -457,6 +457,48 @@ pub struct BookWindow { vouchers: Vec, } +/// Incremental admission for raw voucher rows. Both adapters and the core +/// window boundary use this before retaining entry descriptors. +#[derive(Debug, Default)] +pub struct RawObservationBudget { + vouchers: usize, + entries: usize, + bytes: usize, +} + +impl RawObservationBudget { + pub fn admit<'a>( + &mut self, + entries: impl IntoIterator, + ) -> Result<(), PresenceError> { + self.vouchers = self + .vouchers + .checked_add(1) + .ok_or(PresenceError::WindowTooLarge)?; + if self.vouchers > MAX_WINDOW_VOUCHERS { + return Err(PresenceError::WindowTooLarge); + } + for (ledger, amount) in entries { + self.entries = self + .entries + .checked_add(1) + .ok_or(PresenceError::WindowRawEntryWorkTooLarge)?; + if self.entries > MAX_WINDOW_RAW_ENTRY_WORK { + return Err(PresenceError::WindowRawEntryWorkTooLarge); + } + self.bytes = self + .bytes + .checked_add(ledger.len()) + .and_then(|n| n.checked_add(amount.len())) + .ok_or(PresenceError::WindowRawEntryBytesTooLarge)?; + if self.bytes > MAX_WINDOW_RAW_ENTRY_BYTES { + return Err(PresenceError::WindowRawEntryBytesTooLarge); + } + } + Ok(()) + } +} + impl BookWindow { /// Admits raw observations in aggregate before the per-voucher conversion /// performs decimal parsing, string cloning, and comparison-key folding. @@ -467,31 +509,15 @@ impl BookWindow { remote_id_evidence: RemoteIdEvidence, observations: impl IntoIterator>, ) -> Result { - let mut raw_entries = 0usize; - let mut raw_bytes = 0usize; + let mut budget = RawObservationBudget::default(); let mut vouchers = Vec::new(); for observation in observations { - raw_entries = raw_entries - .checked_add(observation.entries.len()) - .ok_or(PresenceError::WindowRawEntryWorkTooLarge)?; - if raw_entries > MAX_WINDOW_RAW_ENTRY_WORK { - return Err(PresenceError::WindowRawEntryWorkTooLarge); - } - let observation_bytes = observation - .entries - .iter() - .try_fold(0usize, |total, entry| { - total - .checked_add(entry.ledger.len())? - .checked_add(entry.amount.len()) - }) - .ok_or(PresenceError::WindowRawEntryBytesTooLarge)?; - raw_bytes = raw_bytes - .checked_add(observation_bytes) - .ok_or(PresenceError::WindowRawEntryBytesTooLarge)?; - if raw_bytes > MAX_WINDOW_RAW_ENTRY_BYTES { - return Err(PresenceError::WindowRawEntryBytesTooLarge); - } + budget.admit( + observation + .entries + .iter() + .map(|entry| (entry.ledger, entry.amount)), + )?; vouchers.push(BookVoucher::observed(observation)?); } Self::observed(from, to, read, remote_id_evidence, vouchers) diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs index 53d71b07..d7de92e3 100644 --- a/src-tauri/src/agent_presence.rs +++ b/src-tauri/src/agent_presence.rs @@ -11,7 +11,7 @@ use std::collections::BTreeSet; use bridge_tally_core::book_presence::{ self, BookWindow, NumberingDeclaration, NumberingMethod, ObservedEntry, ObservedVoucher, PresenceError, PresenceReport, PresenceRequest, ProposedVoucher, ProposedVoucherInput, - RemoteIdEvidence, WindowRead, + RawObservationBudget, RemoteIdEvidence, WindowRead, }; use bridge_tally_core::master_binding::{MasterCatalog, MasterClass, SourceEntity}; @@ -260,9 +260,20 @@ fn book_window( read: WindowRead, rows: &[Value], ) -> Result { - let entries = rows - .iter() - .map(|row| { + let mut budget = RawObservationBudget::default(); + let mut entries = Vec::with_capacity(rows.len()); + for row in rows { + let raw = row["amounts"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default(); + budget.admit(raw.iter().map(|entry| { + ( + entry["ledger"].as_str().unwrap_or_default(), + entry["amount"].as_str().unwrap_or_default(), + ) + }))?; + entries.push( row["amounts"] .as_array() .map(Vec::as_slice) @@ -272,9 +283,9 @@ fn book_window( ledger: entry["ledger"].as_str().unwrap_or_default(), amount: entry["amount"].as_str().unwrap_or_default(), }) - .collect::>() - }) - .collect::>(); + .collect::>(), + ); + } let observations = rows .iter() .zip(&entries) From cd38b8e6912b29095e04d7529c4eadfbb5fde426 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 02:44:39 +0530 Subject: [PATCH 71/91] chore(compatibility): reseal presence boundary --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 0f0dd6a5..6e2e20e3 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": "188ecd6b1b249cbbfb715eb47be8cbea9b9f040a688ff13927271295c57206db", + "compatibility_surface_sha256": "4f0f76e184294a1d6db8211a9d71f05583224dde58b4f3951a745cf2f74b9922", "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 cc233335..9a8b9466 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "c1b573d39cf458cb8b31964751ce4e411c33a1a72c7ee1f33e881342e059b618" + "sha256": "09b119ee037437006872c8c175ebd6a3bd1b5e5249dfb2bd4e3fb110f937e5f1" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -343,7 +343,7 @@ }, { "path": "src-tauri/src/agent_presence.rs", - "sha256": "359fee34db9b9da4f65b177130d96e3437bb5d841c0d0c7efafb66247693e31c" + "sha256": "dd5de3d9fe7eb6494650dd3c0d0af3d319388e0b4b54f5839ecdd2b5d0270419" }, { "path": "src-tauri/src/agent_presence_tests.rs", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "188ecd6b1b249cbbfb715eb47be8cbea9b9f040a688ff13927271295c57206db" + "manifest_sha256": "4f0f76e184294a1d6db8211a9d71f05583224dde58b4f3951a745cf2f74b9922" } \ No newline at end of file From 9f987ce7c8137162561c30de1a273d207db86fc0 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 02:50:17 +0530 Subject: [PATCH 72/91] test(presence): cover aggregate raw observation boundaries --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 +- .../src/book_presence_tests.rs | 144 ++++++++++++++++++ src-tauri/src/agent_presence.rs | 2 +- 4 files changed, 148 insertions(+), 4 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 6e2e20e3..97e8c38f 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": "4f0f76e184294a1d6db8211a9d71f05583224dde58b4f3951a745cf2f74b9922", + "compatibility_surface_sha256": "992cc0ee3ea630c6ed5dc1738cc08cc3b5be25a5daf7417d500f114675d350e1", "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 9a8b9466..1337e12c 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -343,7 +343,7 @@ }, { "path": "src-tauri/src/agent_presence.rs", - "sha256": "dd5de3d9fe7eb6494650dd3c0d0af3d319388e0b4b54f5839ecdd2b5d0270419" + "sha256": "82ce8a17c3902cb9f4f7d0718b8792984f677da435b551bbccc92731a2971458" }, { "path": "src-tauri/src/agent_presence_tests.rs", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "4f0f76e184294a1d6db8211a9d71f05583224dde58b4f3951a745cf2f74b9922" + "manifest_sha256": "992cc0ee3ea630c6ed5dc1738cc08cc3b5be25a5daf7417d500f114675d350e1" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 87253cce..9824d49d 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1625,6 +1625,150 @@ fn raw_observations_are_bounded_before_voucher_conversion() { .is_ok()); } +#[test] +fn raw_entry_work_is_bounded_across_valid_voucher_sized_rows() { + let entry = ObservedEntry { + ledger: "Cash", + amount: "1.00", + }; + let full_voucher_entries = vec![entry; MAX_ENTRIES_PER_VOUCHER]; + let mut rows = (0..(MAX_WINDOW_RAW_ENTRY_WORK / MAX_ENTRIES_PER_VOUCHER)) + .map(|position| ObservedVoucher { + key: Box::leak(format!("full-{position}").into_boxed_str()), + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: &full_voucher_entries, + cancelled: false, + optional: false, + }) + .collect::>(); + rows.push(ObservedVoucher { + key: "one-over", + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: std::slice::from_ref(&entry), + cancelled: false, + optional: false, + }); + assert_eq!( + BookWindow::from_observations( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + rows, + ) + .expect_err("the aggregate raw-entry limit must span valid rows"), + PresenceError::WindowRawEntryWorkTooLarge + ); +} + +#[test] +fn raw_entry_bytes_admit_exact_limit_and_refuse_the_next_byte() { + let amount = "1"; + let ledger_1023: &'static str = Box::leak("x".repeat(1_023).into_boxed_str()); + let ledger_1024: &'static str = Box::leak("y".repeat(1_024).into_boxed_str()); + let entry_1024 = ObservedEntry { + ledger: ledger_1023, + amount, + }; + let exact_entries = vec![entry_1024; MAX_WINDOW_RAW_ENTRY_BYTES / 1_024]; + let exact_rows = [ + ObservedVoucher { + key: "bytes-0", + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: &exact_entries[..MAX_ENTRIES_PER_VOUCHER], + cancelled: false, + optional: false, + }, + ObservedVoucher { + key: "bytes-1", + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: &exact_entries[MAX_ENTRIES_PER_VOUCHER..2 * MAX_ENTRIES_PER_VOUCHER], + cancelled: false, + optional: false, + }, + ObservedVoucher { + key: "bytes-2", + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: &exact_entries[2 * MAX_ENTRIES_PER_VOUCHER..], + cancelled: false, + optional: false, + }, + ]; + assert!(BookWindow::from_observations( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + exact_rows, + ) + .is_ok()); + let extra = ObservedEntry { + ledger: ledger_1024, + amount, + }; + let over_rows = [ + exact_rows[0].clone(), + exact_rows[1].clone(), + exact_rows[2].clone(), + ObservedVoucher { + key: "bytes-over", + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: std::slice::from_ref(&extra), + cancelled: false, + optional: false, + }, + ]; + assert_eq!( + BookWindow::from_observations( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + over_rows, + ) + .expect_err("one byte over the aggregate raw-byte limit must refuse"), + PresenceError::WindowRawEntryBytesTooLarge + ); +} + +#[test] +fn raw_observation_voucher_count_budget_counts_zero_entry_admissions() { + let mut budget = RawObservationBudget::default(); + for _ in 0..MAX_WINDOW_VOUCHERS { + assert!(budget.admit(std::iter::empty()).is_ok()); + } + assert_eq!( + budget + .admit(std::iter::empty()) + .expect_err("next voucher exceeds the count budget"), + PresenceError::WindowTooLarge + ); +} + #[test] fn a_window_bounds_aggregate_ledger_key_bytes_before_indexing() { let ledgers = (0..(MAX_WINDOW_LEDGER_KEY_BYTES / MAX_TEXT_CHARS + 1)) diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs index d7de92e3..039ce01f 100644 --- a/src-tauri/src/agent_presence.rs +++ b/src-tauri/src/agent_presence.rs @@ -261,7 +261,7 @@ fn book_window( rows: &[Value], ) -> Result { let mut budget = RawObservationBudget::default(); - let mut entries = Vec::with_capacity(rows.len()); + let mut entries = Vec::with_capacity(rows.len().min(book_presence::MAX_WINDOW_VOUCHERS)); for row in rows { let raw = row["amounts"] .as_array() From 122690049280e93fddb32ddc19cd948cf8972fac Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 02:53:03 +0530 Subject: [PATCH 73/91] Verify the adjacent raw-byte admission boundary --- .../src/book_presence_tests.rs | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 9824d49d..05621ee2 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1726,22 +1726,24 @@ fn raw_entry_bytes_admit_exact_limit_and_refuse_the_next_byte() { ledger: ledger_1024, amount, }; + let mut over_tail = exact_entries[2 * MAX_ENTRIES_PER_VOUCHER..].to_vec(); + *over_tail.last_mut().expect("nonempty tail") = extra; let over_rows = [ - exact_rows[0].clone(), - exact_rows[1].clone(), - exact_rows[2].clone(), + exact_rows[0], + exact_rows[1], ObservedVoucher { - key: "bytes-over", - date: "20260812", - voucher_type: "Sales", - voucher_number: None, - remote_id: None, - party: None, - entries: std::slice::from_ref(&extra), - cancelled: false, - optional: false, + entries: &over_tail, + ..exact_rows[2] }, ]; + assert_eq!( + over_rows + .iter() + .flat_map(|row| row.entries) + .map(|entry| entry.ledger.len() + entry.amount.len()) + .sum::(), + MAX_WINDOW_RAW_ENTRY_BYTES + 1, + ); assert_eq!( BookWindow::from_observations( "20260801", From 5b967f1e0c4717bc6b2298c2a40789cf71bd2edd Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 02:54:06 +0530 Subject: [PATCH 74/91] Reseal the retained presence surface with its matching tool --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 97e8c38f..6d5e1624 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": "992cc0ee3ea630c6ed5dc1738cc08cc3b5be25a5daf7417d500f114675d350e1", + "compatibility_surface_sha256": "d0377443c9345c566e3d24cf33045b265f8a55cda37e8ce736531a7d00990cc4", "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 1337e12c..591d0638 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -147,7 +147,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "fee1d066c63446dbadf4efb7a9b795399899b0b7780cd8ad2a234de4b2f1dbf0" + "sha256": "daa8640b0cd90799d108591f723481063794dfd6bc44d17b8b371601fb433284" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -335,7 +335,7 @@ }, { "path": "src-tauri/src/agent_import.rs", - "sha256": "c186d5d8618ce1b92ff02cf4451abf5e76eb435647e3c9ecae6e5aaea6210ba5" + "sha256": "aaed843e02cb3410b9aca9ab5bae46ea15b0d7bc7866f37f5c45edfaa1b8f117" }, { "path": "src-tauri/src/agent_ledgers.rs", @@ -595,7 +595,7 @@ }, { "path": "src-tauri/src/source_draft/catalog.rs", - "sha256": "a4711dd09fabc3701dbdf37b02a08f7e02ed1ec500bae6e58468166c209c112f" + "sha256": "9b6fce29bb15eb0f71e48bcd364244b1e16893e13f24050cead465642a640184" }, { "path": "src-tauri/src/source_draft/files.rs", @@ -735,7 +735,7 @@ }, { "path": "src/SourceDraftScreen.tsx", - "sha256": "894f61d6f440167f1d6eeac53460a748ffc5117dc9fbc5293521bc0772342cee" + "sha256": "18b991c944f6799f81a0c5dbdf58bed5650075923bf369e639db02333a278e8a" }, { "path": "src/TallyReadinessFlow.tsx", @@ -791,7 +791,7 @@ }, { "path": "src/source-draft-types.ts", - "sha256": "dffb4d4ef8c0813a63d4da19678290877ca83795da4da877902a40a95f12a5cf" + "sha256": "37bb64ec9d35d62d405e48e665f4b67335772ee899c049e387bf8684d7c5cdaa" }, { "path": "src/source-draft.css", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "992cc0ee3ea630c6ed5dc1738cc08cc3b5be25a5daf7417d500f114675d350e1" + "manifest_sha256": "d0377443c9345c566e3d24cf33045b265f8a55cda37e8ce736531a7d00990cc4" } \ No newline at end of file From ead47bf3aa2e951b85c3026b948e3899989761a8 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 02:55:06 +0530 Subject: [PATCH 75/91] test(presence): retain exact candidate count semantics --- src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 05621ee2..eb8aff98 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -1881,6 +1881,7 @@ fn an_aggregately_truncated_candidate_list_withholds_absent() { candidates: master_binding::Candidates::Truncated { listed: Vec::new(), found: 7, + count_is_lower_bound: false, }, }), }; From 4f5d39396f9c5d2b7e32d69eb950cfb04a531ef7 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 03:30:19 +0530 Subject: [PATCH 76/91] Bound raw voucher proposal admission --- docs/adr/0017-voucher-presence-authority.md | 6 +- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 8 +- .../bridge-tally-core/src/book_presence.rs | 191 +++++++++++++++++- .../src/book_presence_tests.rs | 106 ++++++++++ src-tauri/src/agent_presence.rs | 112 +++++++--- src-tauri/src/agent_presence_tests.rs | 20 +- 7 files changed, 392 insertions(+), 53 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 9c69efdb..12f22025 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -276,8 +276,10 @@ padding is a transport artefact; internal whitespace, case and punctuation are them so fails toward the noisy direction — a non-match withholds a decisive identity result rather than treating two distinct invoices as the same one. -Voucher *types* keep the master key, because a voucher type is a Tally master -and §3.3b measured that case. +Voucher *types* preserve the source spelling exactly. No case, whitespace, or +separator folding is qualified for voucher types; the type must match the +declared numbering spelling exactly. The master-name comparison key is for +ledger names only, and must not be reused for voucher types. A manual number decides only **within an observed voucher type** — numbers are a per-type series, so a match diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 6d5e1624..5575e589 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": "d0377443c9345c566e3d24cf33045b265f8a55cda37e8ce736531a7d00990cc4", + "compatibility_surface_sha256": "614a1d708be6239c5ee25332f0601d8a93353c89255cb700e930a3d2425c1905", "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 591d0638..9f1a8f6c 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "09b119ee037437006872c8c175ebd6a3bd1b5e5249dfb2bd4e3fb110f937e5f1" + "sha256": "d52c08c7012e059e5d0a0025973a62b514236eee70e2f8ff12f1b6937e85c8a2" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -343,11 +343,11 @@ }, { "path": "src-tauri/src/agent_presence.rs", - "sha256": "82ce8a17c3902cb9f4f7d0718b8792984f677da435b551bbccc92731a2971458" + "sha256": "5dbb64abd1de587f6ea1adf3b987db0e3ebec09d539efa78e37e810ee112cc7a" }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "8fb62eaa288cff6b45534132da81f6e4ecb309f1b78b6d11e5a01b8bf2bab5c8" + "sha256": "775caa7583db6f7e6180d85330db883a7d8221ed1349469e48c1ec4bc6786bc1" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "d0377443c9345c566e3d24cf33045b265f8a55cda37e8ce736531a7d00990cc4" + "manifest_sha256": "614a1d708be6239c5ee25332f0601d8a93353c89255cb700e930a3d2425c1905" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 08b5884b..8efe01cd 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -49,6 +49,11 @@ pub const MAX_ENTRIES_PER_VOUCHER: usize = 2_000; pub const MAX_WINDOW_RAW_ENTRY_WORK: usize = 100_000; /// Aggregate raw entry bytes admitted before parsing, cloning, or folding them. pub const MAX_WINDOW_RAW_ENTRY_BYTES: usize = 4 * 1024 * 1024; +/// Proposal input shares the same aggregate work and byte ceilings as a book +/// window. Admission happens while the borrowed input is still raw, before +/// decimal parsing or any string is cloned. +pub const MAX_PROPOSAL_RAW_ENTRY_WORK: usize = MAX_WINDOW_RAW_ENTRY_WORK; +pub const MAX_PROPOSAL_RAW_BYTES: usize = MAX_WINDOW_RAW_ENTRY_BYTES; /// Most distinct voucher-to-ledger memberships retained across one window. /// /// `WindowIndex` must retain every membership once more to find party @@ -137,6 +142,12 @@ pub enum PresenceError { ProposalsEmpty, #[error("proposed voucher list exceeded its bound")] TooManyProposals, + #[error("two proposed vouchers carried the same source position")] + DuplicateProposalPosition, + #[error("proposed voucher raw entries exceeded their aggregate bound")] + ProposalRawEntryWorkTooLarge, + #[error("proposed voucher raw metadata exceeded its aggregate byte bound")] + ProposalRawBytesTooLarge, #[error("proposal and book window comparison work exceeded its bound")] ComparisonWorkTooLarge, #[error("voucher entry list was empty")] @@ -190,6 +201,9 @@ impl PresenceError { Self::WindowDoesNotCover => "presence_window_does_not_cover", Self::ProposalsEmpty => "presence_proposals_empty", Self::TooManyProposals => "presence_proposals_too_many", + Self::DuplicateProposalPosition => "presence_duplicate_proposal_position", + Self::ProposalRawEntryWorkTooLarge => "presence_proposal_raw_entry_work_too_large", + Self::ProposalRawBytesTooLarge => "presence_proposal_raw_bytes_too_large", Self::ComparisonWorkTooLarge => "presence_comparison_work_too_large", Self::EntriesEmpty => "presence_entries_empty", Self::TooManyEntries => "presence_entries_too_many", @@ -299,6 +313,91 @@ pub struct ProposedVoucherInput<'a> { pub entries: &'a [ObservedEntry<'a>], } +/// Aggregate admission for a proposal batch. The input is borrowed so this +/// check runs before parsing decimals and before `ProposedVoucher` clones any +/// metadata. Callers that accept external proposal batches must use this +/// boundary rather than constructing a large converted vector first. +#[derive(Debug, Default)] +pub struct RawProposalBudget { + proposals: usize, + entries: usize, + entry_bytes: usize, + metadata_bytes: usize, +} + +impl RawProposalBudget { + pub fn admit(&mut self, input: ProposedVoucherInput<'_>) -> Result<(), PresenceError> { + self.admit_parts( + input.position, + input.date, + input.voucher_type, + input.voucher_number, + input.remote_id, + input.party, + input + .entries + .iter() + .map(|entry| (entry.ledger, entry.amount)), + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn admit_parts<'a>( + &mut self, + _position: usize, + date: &'a str, + voucher_type: &'a str, + voucher_number: Option<&'a str>, + remote_id: Option<&'a str>, + party: Option<&'a str>, + entries: impl IntoIterator, + ) -> Result<(), PresenceError> { + self.proposals = self + .proposals + .checked_add(1) + .ok_or(PresenceError::TooManyProposals)?; + if self.proposals > MAX_PROPOSED_VOUCHERS { + return Err(PresenceError::TooManyProposals); + } + let metadata = [ + date, + voucher_type, + voucher_number.unwrap_or_default(), + remote_id.unwrap_or_default(), + party.unwrap_or_default(), + ]; + let metadata_bytes = metadata + .iter() + .try_fold(0usize, |total, value| total.checked_add(value.len())) + .ok_or(PresenceError::ProposalRawBytesTooLarge)?; + self.metadata_bytes = self + .metadata_bytes + .checked_add(metadata_bytes) + .ok_or(PresenceError::ProposalRawBytesTooLarge)?; + if self.metadata_bytes > MAX_PROPOSAL_RAW_BYTES { + return Err(PresenceError::ProposalRawBytesTooLarge); + } + for (ledger, amount) in entries { + self.entries = self + .entries + .checked_add(1) + .ok_or(PresenceError::ProposalRawEntryWorkTooLarge)?; + if self.entries > MAX_PROPOSAL_RAW_ENTRY_WORK { + return Err(PresenceError::ProposalRawEntryWorkTooLarge); + } + self.entry_bytes = self + .entry_bytes + .checked_add(ledger.len()) + .and_then(|total| total.checked_add(amount.len())) + .ok_or(PresenceError::ProposalRawBytesTooLarge)?; + if self.entry_bytes > MAX_PROPOSAL_RAW_BYTES { + return Err(PresenceError::ProposalRawBytesTooLarge); + } + } + Ok(()) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct BookVoucher { key: String, @@ -399,7 +498,14 @@ pub struct ProposedVoucher { } impl ProposedVoucher { - pub fn new(input: ProposedVoucherInput<'_>) -> Result { + #[allow(dead_code)] + pub(crate) fn new(input: ProposedVoucherInput<'_>) -> Result { + let mut budget = RawProposalBudget::default(); + budget.admit(input)?; + Self::new_admitted(input) + } + + fn new_admitted(input: ProposedVoucherInput<'_>) -> Result { let date = TallyDate::parse(input.date.to_string()).map_err(|_| PresenceError::DateInvalid)?; let voucher_type = validated_text(input.voucher_type)?; @@ -422,6 +528,23 @@ impl ProposedVoucher { }) } + /// Convert a raw proposal batch only after one aggregate admission pass. + pub fn from_inputs<'a>( + inputs: impl IntoIterator>, + ) -> Result, PresenceError> { + let mut budget = RawProposalBudget::default(); + let mut positions = BTreeSet::new(); + let mut converted = Vec::new(); + for input in inputs { + budget.admit(input)?; + if !positions.insert(input.position) { + return Err(PresenceError::DuplicateProposalPosition); + } + converted.push(Self::new_admitted(input)?); + } + Ok(converted) + } + pub fn date(&self) -> &str { self.date.as_str() } @@ -464,9 +587,61 @@ pub struct RawObservationBudget { vouchers: usize, entries: usize, bytes: usize, + metadata_bytes: usize, } impl RawObservationBudget { + #[allow(clippy::too_many_arguments)] + pub fn admit_fields<'a>( + &mut self, + key: &'a str, + date: &'a str, + voucher_type: &'a str, + voucher_number: Option<&'a str>, + remote_id: Option<&'a str>, + party: Option<&'a str>, + entries: impl IntoIterator, + ) -> Result<(), PresenceError> { + let metadata = [ + key, + date, + voucher_type, + voucher_number.unwrap_or_default(), + remote_id.unwrap_or_default(), + party.unwrap_or_default(), + ]; + let metadata_bytes = metadata + .iter() + .try_fold(0usize, |total, value| total.checked_add(value.len())) + .ok_or(PresenceError::WindowRawEntryBytesTooLarge)?; + self.metadata_bytes = self + .metadata_bytes + .checked_add(metadata_bytes) + .ok_or(PresenceError::WindowRawEntryBytesTooLarge)?; + if self.metadata_bytes > MAX_WINDOW_RAW_ENTRY_BYTES { + return Err(PresenceError::WindowRawEntryBytesTooLarge); + } + self.admit(entries) + } + + pub fn admit_observation( + &mut self, + observation: &ObservedVoucher<'_>, + ) -> Result<(), PresenceError> { + self.admit_fields( + observation.key, + observation.date, + observation.voucher_type, + observation.voucher_number, + observation.remote_id, + observation.party, + observation + .entries + .iter() + .map(|entry| (entry.ledger, entry.amount)), + ) + } + pub fn admit<'a>( &mut self, entries: impl IntoIterator, @@ -512,12 +687,7 @@ impl BookWindow { let mut budget = RawObservationBudget::default(); let mut vouchers = Vec::new(); for observation in observations { - budget.admit( - observation - .entries - .iter() - .map(|entry| (entry.ledger, entry.amount)), - )?; + budget.admit_observation(&observation)?; vouchers.push(BookVoucher::observed(observation)?); } Self::observed(from, to, read, remote_id_evidence, vouchers) @@ -1033,6 +1203,13 @@ impl<'a> PresenceRequest<'a> { if proposals.len() > MAX_PROPOSED_VOUCHERS { return Err(PresenceError::TooManyProposals); } + let mut positions = BTreeSet::new(); + if proposals + .iter() + .any(|proposal| !positions.insert(proposal.position())) + { + return Err(PresenceError::DuplicateProposalPosition); + } let comparisons = proposals .len() .checked_mul(window.vouchers().len()) diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index eb8aff98..27ae42d3 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -3199,3 +3199,109 @@ fn a_number_collision_still_reaches_what_it_only_resembled() { "book-3 was plainly resembled; the collision must not hide that" ); } + +#[test] +fn raw_proposal_budget_counts_all_entry_work_before_conversion() { + let entries = vec![ + ObservedEntry { + ledger: "L", + amount: "1" + }; + 2_000 + ]; + let input = ProposedVoucherInput { + position: 0, + date: "20260812", + voucher_type: "Receipt", + voucher_number: Some("1"), + remote_id: None, + party: None, + entries: &entries, + }; + let mut budget = RawProposalBudget::default(); + for _ in 0..50 { + budget.admit(input).expect("100,000 entries are admitted"); + } + assert_eq!( + budget.admit(input), + Err(PresenceError::ProposalRawEntryWorkTooLarge) + ); +} + +#[test] +fn raw_proposal_budget_counts_metadata_bytes_before_conversion() { + let metadata = "x".repeat(MAX_PROPOSAL_RAW_BYTES - "20260812".len() - "Receipt".len()); + let input = ProposedVoucherInput { + position: 0, + date: "20260812", + voucher_type: "Receipt", + voucher_number: None, + remote_id: None, + party: Some(&metadata), + entries: &[], + }; + let mut budget = RawProposalBudget::default(); + budget.admit(input).expect("exact byte limit"); + let next = ProposedVoucherInput { + position: 1, + voucher_number: Some("12345678"), + ..input + }; + assert_eq!( + budget.admit(next), + Err(PresenceError::ProposalRawBytesTooLarge) + ); +} + +#[test] +fn raw_observation_budget_counts_retained_voucher_metadata() { + let key = "x".repeat(MAX_WINDOW_RAW_ENTRY_BYTES); + let observation = ObservedVoucher { + key: &key, + date: "20260812", + voucher_type: "Receipt", + voucher_number: None, + remote_id: None, + party: None, + entries: &[], + cancelled: false, + optional: false, + }; + let mut budget = RawObservationBudget::default(); + assert_eq!( + budget.admit_observation(&observation), + Err(PresenceError::WindowRawEntryBytesTooLarge) + ); +} + +#[test] +fn proposal_batch_rejects_duplicate_source_positions_before_conversion() { + let rows = [ObservedEntry { + ledger: "L", + amount: "1", + }]; + let inputs = [ + ProposedVoucherInput { + position: 7, + date: "20260812", + voucher_type: "Receipt", + voucher_number: Some("1"), + remote_id: None, + party: None, + entries: &rows, + }, + ProposedVoucherInput { + position: 7, + date: "20260812", + voucher_type: "Receipt", + voucher_number: Some("2"), + remote_id: None, + party: None, + entries: &rows, + }, + ]; + assert_eq!( + ProposedVoucher::from_inputs(inputs), + Err(PresenceError::DuplicateProposalPosition) + ); +} diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs index 039ce01f..83790fc0 100644 --- a/src-tauri/src/agent_presence.rs +++ b/src-tauri/src/agent_presence.rs @@ -267,12 +267,20 @@ fn book_window( .as_array() .map(Vec::as_slice) .unwrap_or_default(); - budget.admit(raw.iter().map(|entry| { - ( - entry["ledger"].as_str().unwrap_or_default(), - entry["amount"].as_str().unwrap_or_default(), - ) - }))?; + budget.admit_fields( + row["guid"].as_str().unwrap_or_default(), + row["date"].as_str().unwrap_or_default(), + row["voucher_type"].as_str().unwrap_or_default(), + row["voucher_number"].as_str(), + None, + row["party"].as_str(), + raw.iter().map(|entry| { + ( + entry["ledger"].as_str().unwrap_or_default(), + entry["amount"].as_str().unwrap_or_default(), + ) + }), + )?; entries.push( row["amounts"] .as_array() @@ -336,36 +344,84 @@ fn parse_proposals(args: &Value) -> Result, String> { .and_then(Value::as_array) .ok_or_else(|| "vouchers_required".to_string())?; let invalid = || "argument_invalid:vouchers".to_string(); - let mut parsed = Vec::with_capacity(proposed.len()); + struct RawProposal { + date: String, + voucher_type: String, + voucher_number: Option, + party: Option, + entries: Vec<(String, String)>, + } + let mut raw = Vec::with_capacity(proposed.len().min(book_presence::MAX_PROPOSED_VOUCHERS)); + let mut admission = bridge_tally_core::book_presence::RawProposalBudget::default(); for (position, voucher) in proposed.iter().enumerate() { - let date = normalized_date(voucher["date"].as_str().ok_or_else(invalid)?)?; + let raw_date = voucher["date"].as_str().ok_or_else(invalid)?; + let raw_type = voucher["voucher_type"].as_str().ok_or_else(invalid)?; let rows = voucher["entries"].as_array().ok_or_else(invalid)?; + // Admit the complete borrowed shape before date/decimal parsing or + // cloning any proposal metadata. The shared core repeats this check + // for callers that do not use the JSON adapter. + admission + .admit_parts( + position, + raw_date, + raw_type, + voucher["voucher_number"].as_str(), + None, + voucher["party"].as_str(), + rows.iter().map(|entry| { + ( + entry["ledger"].as_str().unwrap_or_default(), + entry["amount"].as_str().unwrap_or_default(), + ) + }), + ) + .map_err(|error| error.safe_reason_code().to_string())?; + let date = normalized_date(raw_date)?; let entries = rows .iter() .map(|entry| { - Ok(ObservedEntry { - ledger: entry["ledger"].as_str().ok_or_else(invalid)?, - amount: entry["amount"].as_str().ok_or_else(invalid)?, - }) + Ok(( + entry["ledger"].as_str().ok_or_else(invalid)?.to_string(), + entry["amount"].as_str().ok_or_else(invalid)?.to_string(), + )) }) .collect::, String>>()?; - parsed.push( - ProposedVoucher::new(ProposedVoucherInput { - position, - date: &date, - voucher_type: voucher["voucher_type"].as_str().ok_or_else(invalid)?, - voucher_number: voucher["voucher_number"].as_str(), - // Not an accepted input: the shipped read cannot fetch - // REMOTEID, so a supplied one could only ever withhold a - // verdict. The crate keeps the basis for callers that can. - remote_id: None, - party: voucher["party"].as_str(), - entries: &entries, - }) - .map_err(|error| error.safe_reason_code().to_string())?, - ); + raw.push(RawProposal { + date, + voucher_type: voucher["voucher_type"] + .as_str() + .ok_or_else(invalid)? + .to_string(), + voucher_number: voucher["voucher_number"].as_str().map(str::to_string), + party: voucher["party"].as_str().map(str::to_string), + entries, + }); } - Ok(parsed) + // Materialize entry descriptors so their borrowed slices outlive the + // batch conversion; admission still precedes decimal parsing and clones. + let descriptors: Vec>> = raw + .iter() + .map(|voucher| { + voucher + .entries + .iter() + .map(|(ledger, amount)| ObservedEntry { ledger, amount }) + .collect() + }) + .collect(); + let inputs = raw + .iter() + .enumerate() + .map(|(position, voucher)| ProposedVoucherInput { + position, + date: &voucher.date, + voucher_type: &voucher.voucher_type, + voucher_number: voucher.voucher_number.as_deref(), + remote_id: None, + party: voucher.party.as_deref(), + entries: &descriptors[position], + }); + ProposedVoucher::from_inputs(inputs).map_err(|error| error.safe_reason_code().to_string()) } /// Bounds the fixed observations, so a diagnostic can never cost the answer. diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index 697a43a6..c9ccf241 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -231,18 +231,16 @@ fn a_caller_limited_presence_page_includes_its_resume_cursor() { amount: "1.00", }, ]; - let proposals = [0, 1].map(|position| { - ProposedVoucher::new(ProposedVoucherInput { - position, - date: "20260901", - voucher_type: "Journal", - voucher_number: Some(if position == 0 { "JV-0" } else { "JV-1" }), - remote_id: None, - party: None, - entries: &entries, - }) - .expect("proposal") + let proposal_inputs = [0, 1].map(|position| ProposedVoucherInput { + position, + date: "20260901", + voucher_type: "Journal", + voucher_number: Some(if position == 0 { "JV-0" } else { "JV-1" }), + remote_id: None, + party: None, + entries: &entries, }); + let proposals = ProposedVoucher::from_inputs(proposal_inputs).expect("proposals"); let window = BookWindow::from_observations( "20260901", "20260930", From 935c506f0196151a2c4f1a335464608591383688 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 03:33:48 +0530 Subject: [PATCH 77/91] Require admitted proposal batches --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 6 +-- .../bridge-tally-core/src/book_presence.rs | 46 +++++++++++++++++-- src-tauri/src/agent_presence.rs | 6 ++- 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 5575e589..01e61394 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": "614a1d708be6239c5ee25332f0601d8a93353c89255cb700e930a3d2425c1905", + "compatibility_surface_sha256": "874ff21dd7adbde5921d81f51a67c1e45ac53b24e4b38338a81360380d8f008f", "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 9f1a8f6c..37f22146 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "d52c08c7012e059e5d0a0025973a62b514236eee70e2f8ff12f1b6937e85c8a2" + "sha256": "16819ca298f5c91d3bccb7fbd8f528d7fd71e31c6aa6b298f7e4e0f7a8b80d55" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -343,7 +343,7 @@ }, { "path": "src-tauri/src/agent_presence.rs", - "sha256": "5dbb64abd1de587f6ea1adf3b987db0e3ebec09d539efa78e37e810ee112cc7a" + "sha256": "8c21bf41b456fcc9d748f6f80e6fc08446c3810375df21a508efc1e0b2a36414" }, { "path": "src-tauri/src/agent_presence_tests.rs", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "614a1d708be6239c5ee25332f0601d8a93353c89255cb700e930a3d2425c1905" + "manifest_sha256": "874ff21dd7adbde5921d81f51a67c1e45ac53b24e4b38338a81360380d8f008f" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 8efe01cd..03236a1a 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -498,7 +498,7 @@ pub struct ProposedVoucher { } impl ProposedVoucher { - #[allow(dead_code)] + #[cfg(test)] pub(crate) fn new(input: ProposedVoucherInput<'_>) -> Result { let mut budget = RawProposalBudget::default(); budget.admit(input)?; @@ -531,7 +531,7 @@ impl ProposedVoucher { /// Convert a raw proposal batch only after one aggregate admission pass. pub fn from_inputs<'a>( inputs: impl IntoIterator>, - ) -> Result, PresenceError> { + ) -> Result { let mut budget = RawProposalBudget::default(); let mut positions = BTreeSet::new(); let mut converted = Vec::new(); @@ -542,7 +542,9 @@ impl ProposedVoucher { } converted.push(Self::new_admitted(input)?); } - Ok(converted) + Ok(ProposedBatch { + vouchers: converted, + }) } pub fn date(&self) -> &str { @@ -569,6 +571,24 @@ impl ProposedVoucher { } } +/// An admitted proposal batch. Its only production constructor performs the +/// aggregate raw admission before conversion, so callers cannot concatenate +/// independently converted vectors and evade the batch budget. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProposedBatch { + vouchers: Vec, +} + +impl ProposedBatch { + pub fn as_slice(&self) -> &[ProposedVoucher] { + &self.vouchers + } + + pub fn iter(&self) -> impl Iterator { + self.vouchers.iter() + } +} + /// One observed window of a company's book. It can only be constructed from a /// read that observed its whole range, so "the window was too dense to read" /// can never reach a comparison as "nothing matched". @@ -1180,11 +1200,31 @@ struct PartyResolution { } impl<'a> PresenceRequest<'a> { + #[cfg(not(test))] + pub fn new( + window: &'a BookWindow, + catalog: &'a MasterCatalog, + numbering: &'a NumberingDeclaration, + proposals: &'a ProposedBatch, + ) -> Result { + Self::new_inner(window, catalog, numbering, proposals.as_slice()) + } + + #[cfg(test)] pub fn new( window: &'a BookWindow, catalog: &'a MasterCatalog, numbering: &'a NumberingDeclaration, proposals: &'a [ProposedVoucher], + ) -> Result { + Self::new_inner(window, catalog, numbering, proposals) + } + + fn new_inner( + window: &'a BookWindow, + catalog: &'a MasterCatalog, + numbering: &'a NumberingDeclaration, + proposals: &'a [ProposedVoucher], ) -> Result { if catalog.class() != MasterClass::Ledger { return Err(PresenceError::CatalogClassInvalid); diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs index 83790fc0..53c44f80 100644 --- a/src-tauri/src/agent_presence.rs +++ b/src-tauri/src/agent_presence.rs @@ -77,7 +77,7 @@ impl Server { // they are settled here rather than after three Tally reads. The crate // enforces them again at its own boundary; this only stops a request // that was always going to be refused from exercising the endpoint. - for proposal in &proposals { + for proposal in proposals.iter() { // A party's *entity shape* -- how many identifiers its name // carries -- is decided entirely by the caller's text, and the // crate parses it inside `PresenceRequest::new`, three reads @@ -338,7 +338,9 @@ fn parse_numbering(args: &Value) -> Result { NumberingDeclaration::new(entries).map_err(|error| error.safe_reason_code().to_string()) } -fn parse_proposals(args: &Value) -> Result, String> { +fn parse_proposals( + args: &Value, +) -> Result { let proposed = args .get("vouchers") .and_then(Value::as_array) From 6ce6e0c9b922c3ae562a1a413cde3bc326131afc Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 03:39:58 +0530 Subject: [PATCH 78/91] test: qualify exact raw admission boundaries --- docs/adr/0017-voucher-presence-authority.md | 9 +- .../src/book_presence_tests.rs | 200 ++++++++++++++---- 2 files changed, 163 insertions(+), 46 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 12f22025..e10cffe2 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -164,7 +164,14 @@ and widening the window is the caller's decision, made in the open. Raw book observations are admitted before decimal parsing, cloning, or folding: **100,000** total entries and **4 MiB** of entry ledger-and-amount bytes are -the aggregate limits. Admission is also bounded before comparison: a request above **1,000,000** +the entry limits. Retained voucher metadata is separately bounded to **4 MiB** +before cloning. Raw proposal batches likewise admit at most **5,000** proposals, +**100,000** entries, **4 MiB** of entry bytes and **4 MiB** of retained metadata. +The opaque admitted batch is required by the core request, so separately +converted vectors cannot be concatenated around admission. Source positions +must be unique across that batch. + +Admission is also bounded before comparison: a request above **1,000,000** proposal/window pairs, or above **5,000,000** aggregate indexed resemblance work units, is refused as `ComparisonWorkTooLarge`. The second limit counts posting-list walks and party-key checks, so it still applies when the pair count diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 27ae42d3..2df75982 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -3209,71 +3209,181 @@ fn raw_proposal_budget_counts_all_entry_work_before_conversion() { }; 2_000 ]; - let input = ProposedVoucherInput { - position: 0, - date: "20260812", - voucher_type: "Receipt", - voucher_number: Some("1"), - remote_id: None, - party: None, - entries: &entries, + let inputs = (0..50) + .map(|position| ProposedVoucherInput { + position, + date: "20260812", + voucher_type: "Receipt", + voucher_number: Some("1"), + remote_id: None, + party: None, + entries: &entries, + }) + .collect::>(); + let admitted = + ProposedVoucher::from_inputs(inputs.iter().copied()).expect("exact 100,000 entries"); + assert_eq!(admitted.as_slice().len(), 50); + let extra = [ObservedEntry { + ledger: "L", + amount: "not-an-amount", + }]; + let next = ProposedVoucherInput { + position: 50, + entries: &extra, + ..inputs[0] }; - let mut budget = RawProposalBudget::default(); - for _ in 0..50 { - budget.admit(input).expect("100,000 entries are admitted"); - } assert_eq!( - budget.admit(input), + inputs.iter().map(|v| v.entries.len()).sum::() + next.entries.len(), + MAX_PROPOSAL_RAW_ENTRY_WORK + 1 + ); + assert_eq!( + ProposedVoucher::from_inputs(inputs.into_iter().chain([next])), Err(PresenceError::ProposalRawEntryWorkTooLarge) ); } #[test] fn raw_proposal_budget_counts_metadata_bytes_before_conversion() { - let metadata = "x".repeat(MAX_PROPOSAL_RAW_BYTES - "20260812".len() - "Receipt".len()); - let input = ProposedVoucherInput { - position: 0, - date: "20260812", - voucher_type: "Receipt", - voucher_number: None, - remote_id: None, - party: Some(&metadata), - entries: &[], - }; - let mut budget = RawProposalBudget::default(); - budget.admit(input).expect("exact byte limit"); - let next = ProposedVoucherInput { - position: 1, - voucher_number: Some("12345678"), - ..input - }; + let rows = [ObservedEntry { + ledger: "L", + amount: "1", + }]; + let metadata = "x".repeat(16_384 - "20260812".len() - "Receipt".len() - 1); + let extra_byte = format!("{metadata}x"); + assert!(extra_byte.len() <= MAX_TEXT_CHARS); + let inputs = (0..256) + .map(|position| ProposedVoucherInput { + position, + date: "20260812", + voucher_type: "Receipt", + voucher_number: Some("1"), + remote_id: None, + party: Some(metadata.as_str()), + entries: &rows, + }) + .collect::>(); + let total = inputs + .iter() + .map(|v| { + v.date.len() + + v.voucher_type.len() + + v.voucher_number.unwrap().len() + + v.party.unwrap().len() + }) + .sum::(); + assert_eq!(total, MAX_PROPOSAL_RAW_BYTES); + assert_eq!( + ProposedVoucher::from_inputs(inputs.iter().copied()) + .expect("exact metadata limit") + .as_slice() + .len(), + 256 + ); + let mut over = inputs; + over[255].party = Some(&extra_byte); + assert_eq!( + total + extra_byte.len() - metadata.len(), + MAX_PROPOSAL_RAW_BYTES + 1 + ); assert_eq!( - budget.admit(next), + ProposedVoucher::from_inputs(over), Err(PresenceError::ProposalRawBytesTooLarge) ); } #[test] fn raw_observation_budget_counts_retained_voucher_metadata() { - let key = "x".repeat(MAX_WINDOW_RAW_ENTRY_BYTES); - let observation = ObservedVoucher { - key: &key, - date: "20260812", - voucher_type: "Receipt", - voucher_number: None, - remote_id: None, - party: None, - entries: &[], - cancelled: false, - optional: false, - }; - let mut budget = RawObservationBudget::default(); + let rows = [ObservedEntry { + ledger: "L", + amount: "1", + }]; + let keys = (0..256) + .map(|position| format!("K{position:07}")) + .collect::>(); + let metadata = "x".repeat(16_384 - 8 - "20260812".len() - "Receipt".len()); + let extra_byte = format!("{metadata}x"); + assert!(extra_byte.len() <= MAX_TEXT_CHARS); + let inputs = keys + .iter() + .map(|key| ObservedVoucher { + key, + date: "20260812", + voucher_type: "Receipt", + voucher_number: None, + remote_id: Some(metadata.as_str()), + party: None, + entries: &rows, + cancelled: false, + optional: false, + }) + .collect::>(); + let total = inputs + .iter() + .map(|v| v.key.len() + v.date.len() + v.voucher_type.len() + v.remote_id.unwrap().len()) + .sum::(); + assert_eq!(total, MAX_WINDOW_RAW_ENTRY_BYTES); + assert_eq!( + BookWindow::from_observations( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + inputs.iter().copied() + ) + .expect("exact metadata limit") + .vouchers() + .len(), + 256 + ); + let mut over = inputs; + over[255].remote_id = Some(&extra_byte); + assert_eq!( + total + extra_byte.len() - metadata.len(), + MAX_WINDOW_RAW_ENTRY_BYTES + 1 + ); assert_eq!( - budget.admit_observation(&observation), + BookWindow::from_observations( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + over + ), Err(PresenceError::WindowRawEntryBytesTooLarge) ); } +#[test] +fn raw_proposal_batch_stops_an_unbounded_iterator_at_the_count_limit() { + let rows = [ObservedEntry { + ledger: "L", + amount: "1", + }]; + let seen = std::cell::Cell::new(0); + let inputs = std::iter::from_fn(|| { + let position = seen.get(); + assert!( + position <= MAX_PROPOSED_VOUCHERS, + "must stop after the first excess input" + ); + seen.set(position + 1); + Some(ProposedVoucherInput { + position, + date: "20260812", + voucher_type: "Receipt", + voucher_number: None, + remote_id: None, + party: None, + entries: &rows, + }) + }); + assert_eq!( + ProposedVoucher::from_inputs(inputs), + Err(PresenceError::TooManyProposals) + ); + assert_eq!(seen.get(), MAX_PROPOSED_VOUCHERS + 1); +} + #[test] fn proposal_batch_rejects_duplicate_source_positions_before_conversion() { let rows = [ObservedEntry { From 592ec062f0828de40b275ab011c2aa8d21fa52dc Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 04:02:30 +0530 Subject: [PATCH 79/91] fix: retain remote evidence precedence --- .../bridge-tally-core/src/book_presence.rs | 23 ++++++++++++++- .../src/book_presence_tests.rs | 28 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index 03236a1a..f782d9d9 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -990,6 +990,7 @@ impl UndecidedReason { #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum DifferenceField { + VoucherType, Date, Amount, Party, @@ -1848,6 +1849,20 @@ fn decide( return shell(PresenceStatus::Absent, BTreeSet::new()); } + let touched = found.keys().copied().collect::>(); + // The unread REMOTEID outranks every non-decisive resemblance: carrying + // candidates forward keeps the operator's work item intact, but it cannot + // silently become a weaker reason for withholding absence. + if remote_id_unverifiable { + let mut ordered = found.into_iter().collect::>(); + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::RemoteIdEvidenceUnavailable, + candidates_ranked(window, &mut ordered), + )), + touched, + ); + } let reason = match ( number_matches.is_empty(), type_observed, @@ -1857,7 +1872,6 @@ fn decide( (false, true, false) => UndecidedReason::NumberNotDecisive, _ => UndecidedReason::ResemblesBookVoucher, }; - let touched = found.keys().copied().collect::>(); // Ordered as (position, rule) pairs before anything is cloned: the order is // rule-then-key and only the retained prefix needs a key at all. let mut ordered = found.into_iter().collect::>(); @@ -1955,6 +1969,13 @@ fn differences( voucher: &BookVoucher, ) -> Vec { let mut differences = Vec::new(); + if voucher.voucher_type != proposal.voucher_type { + differences.push(Difference { + field: DifferenceField::VoucherType, + proposed: Some(proposal.voucher_type.clone()), + observed: Some(voucher.voucher_type.clone()), + }); + } if voucher.date() != proposal.date() { differences.push(Difference { field: DifferenceField::Date, diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 2df75982..603a948c 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -2295,6 +2295,34 @@ fn the_same_proposal_is_absent_when_the_window_did_read_remote_ids() { assert!(only(&report).is_absent()); } +#[test] +fn unread_remote_id_outranks_resemblance_but_keeps_its_candidates() { + let unread = BookWindow::observed( + "20260801", "20260831", WindowRead::Complete, RemoteIdEvidence::NotRead, + vec![BookRow::new("book-1", "20260812", "AA0118").build()], + ).expect("window"); + let proposals = [ProposalRow::new(0, "20260812", "AA0777").remote_id("tally-1").build()]; + let report = run(&unread, &catalog(), &numbering(NumberingMethod::Automatic), &proposals); + let entry = only(&report); + assert_eq!(reason(entry), UndecidedReason::RemoteIdEvidenceUnavailable); + assert_eq!(entry.undecided().expect("undecided").candidates[0].book_key, "book-1"); + assert_eq!(report.observations().unmatched_book_vouchers, 0); +} + +#[test] +fn remote_identity_reports_an_exact_voucher_type_difference() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118") + .voucher_type("Receipt").remote_id("tally-1")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .voucher_type("Sales").remote_id("tally-1").build()]; + let report = run(&window, &catalog(), &numbering(NumberingMethod::Manual), &proposals); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { panic!("present") }; + let difference = differences.iter().find(|item| item.field == DifferenceField::VoucherType) + .expect("type difference serialized"); + assert_eq!(difference.proposed.as_deref(), Some("Sales")); + assert_eq!(difference.observed.as_deref(), Some("Receipt")); +} + // --- the response cap must not distort the observations ----------------- #[test] From 47f7863befc0f3b982bcbbb70fe99b1b49301c76 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 04:03:34 +0530 Subject: [PATCH 80/91] test: expose voucher type presence differences --- docs/adr/0017-voucher-presence-authority.md | 9 +++++++++ src-tauri/src/agent_presence_tests.rs | 3 +++ 2 files changed, 12 insertions(+) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index e10cffe2..52284322 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -611,3 +611,12 @@ human-approved batch — this ADR does not move. configuration, presented as an observation, and it decides whether the strongest key is trusted. Exactly the shape of value this project has already been burned by. + +### Unread remote identity and identity findings + +A proposed REMOTEID is stronger than every resemblance. If the window did not +fetch that column, every undecided result that retained a candidate still uses +remote_id_evidence_unavailable; its candidates and touched-book count remain +intact for review. A unique observed REMOTEID can identify a voucher across +exact voucher-type spellings, but the report must then carry a voucher_type +difference with both spellings rather than presenting an empty difference set. diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index c9ccf241..949a03da 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -462,6 +462,7 @@ fn party_names_are_marked_for_egress_and_accounting_selectors_are_not() { "differences": [ {"field": "party", "proposed": "Debtor As Written", "observed": "Bridge Nested Debtor WR4"}, {"field": "amount", "proposed": "12.5", "observed": "11.5"}, + {"field": "voucher_type", "proposed": "Sales", "observed": "Receipt"}, ], }); let marked = mark_presence_party_names(entry); @@ -475,6 +476,8 @@ fn party_names_are_marked_for_egress_and_accounting_selectors_are_not() { ); // An amount is not a party name and must not be wrapped. assert_eq!(marked["differences"][1]["proposed"], "12.5"); + assert_eq!(marked["differences"][2]["field"], "voucher_type"); + assert_eq!(marked["differences"][2]["observed"], "Receipt"); assert_eq!(marked["voucher_number"], "JV-1"); let masked = redact_value(marked, Redaction::MaskParties); let text = masked.to_string(); From 96f3b14ebbd1993b7d211772ef018be95c3cb37f Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 04:03:53 +0530 Subject: [PATCH 81/91] refactor: share presence resemblance reporting --- .../bridge-tally-core/src/book_presence.rs | 19 ++++--------------- .../src/book_presence_tests.rs | 4 ++++ 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index f782d9d9..eec2ac09 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -1850,20 +1850,9 @@ fn decide( } let touched = found.keys().copied().collect::>(); - // The unread REMOTEID outranks every non-decisive resemblance: carrying - // candidates forward keeps the operator's work item intact, but it cannot - // silently become a weaker reason for withholding absence. - if remote_id_unverifiable { - let mut ordered = found.into_iter().collect::>(); - return shell( - PresenceStatus::PossiblyPresent(undecided( - UndecidedReason::RemoteIdEvidenceUnavailable, - candidates_ranked(window, &mut ordered), - )), - touched, - ); - } - let reason = match ( + let reason = if remote_id_unverifiable { + UndecidedReason::RemoteIdEvidenceUnavailable + } else { match ( number_matches.is_empty(), type_observed, method == NumberingMethod::Manual, @@ -1871,7 +1860,7 @@ fn decide( (false, false, _) => UndecidedReason::VoucherTypeNotObserved, (false, true, false) => UndecidedReason::NumberNotDecisive, _ => UndecidedReason::ResemblesBookVoucher, - }; + }}; // Ordered as (position, rule) pairs before anything is cloned: the order is // rule-then-key and only the retained prefix needs a key at all. let mut ordered = found.into_iter().collect::>(); diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 603a948c..eb22652e 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -2321,6 +2321,10 @@ fn remote_identity_reports_an_exact_voucher_type_difference() { .expect("type difference serialized"); assert_eq!(difference.proposed.as_deref(), Some("Sales")); assert_eq!(difference.observed.as_deref(), Some("Receipt")); + assert_eq!( + serde_json::to_value(difference).expect("serialize difference")["field"], + "voucher_type" + ); } // --- the response cap must not distort the observations ----------------- From ef0c780f0c65f2efbd86849a02ef602ff1601f4e Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 04:05:33 +0530 Subject: [PATCH 82/91] chore: reseal presence compatibility surface --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 01e61394..ff770f14 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": "874ff21dd7adbde5921d81f51a67c1e45ac53b24e4b38338a81360380d8f008f", + "compatibility_surface_sha256": "e7fda0fd241edfdc4e4465b003d83b5b35a33bf5110c2cb6f731902c149bb490", "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 37f22146..19e9aed5 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "16819ca298f5c91d3bccb7fbd8f528d7fd71e31c6aa6b298f7e4e0f7a8b80d55" + "sha256": "61090585fe6a99c0caedd4e484281101aa3b2aeb46db30716bf3cfa245bd8b5b" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -347,7 +347,7 @@ }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "775caa7583db6f7e6180d85330db883a7d8221ed1349469e48c1ec4bc6786bc1" + "sha256": "0367c9a91f921311f16ba6ca1a7ee80572ac59c46b0ff99b0edea70c757b5da7" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -862,5 +862,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "874ff21dd7adbde5921d81f51a67c1e45ac53b24e4b38338a81360380d8f008f" + "manifest_sha256": "e7fda0fd241edfdc4e4465b003d83b5b35a33bf5110c2cb6f731902c149bb490" } \ No newline at end of file From 49fb656005a88746d44819def0f96dbcbcbb90b1 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 04:07:37 +0530 Subject: [PATCH 83/91] test: cover unread remote resemblance reasons --- docs/adr/0017-voucher-presence-authority.md | 4 +- .../bridge-tally-core/src/book_presence.rs | 20 ++-- .../src/book_presence_tests.rs | 107 ++++++++++++++++-- 3 files changed, 109 insertions(+), 22 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 52284322..284c6e57 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -615,8 +615,8 @@ human-approved batch — this ADR does not move. ### Unread remote identity and identity findings A proposed REMOTEID is stronger than every resemblance. If the window did not -fetch that column, every undecided result that retained a candidate still uses -remote_id_evidence_unavailable; its candidates and touched-book count remain +fetch that column, nondecisive resemblance candidates use +remote_id_evidence_unavailable; their candidates and touched-book count remain intact for review. A unique observed REMOTEID can identify a voucher across exact voucher-type spellings, but the report must then carry a voucher_type difference with both spellings rather than presenting an empty difference set. diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index eec2ac09..b573aab5 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -1852,15 +1852,17 @@ fn decide( let touched = found.keys().copied().collect::>(); let reason = if remote_id_unverifiable { UndecidedReason::RemoteIdEvidenceUnavailable - } else { match ( - number_matches.is_empty(), - type_observed, - method == NumberingMethod::Manual, - ) { - (false, false, _) => UndecidedReason::VoucherTypeNotObserved, - (false, true, false) => UndecidedReason::NumberNotDecisive, - _ => UndecidedReason::ResemblesBookVoucher, - }}; + } else { + match ( + number_matches.is_empty(), + type_observed, + method == NumberingMethod::Manual, + ) { + (false, false, _) => UndecidedReason::VoucherTypeNotObserved, + (false, true, false) => UndecidedReason::NumberNotDecisive, + _ => UndecidedReason::ResemblesBookVoucher, + } + }; // Ordered as (position, rule) pairs before anything is cloned: the order is // rule-then-key and only the retained prefix needs a key at all. let mut ordered = found.into_iter().collect::>(); diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index eb22652e..da330589 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -2264,7 +2264,7 @@ fn a_remote_id_the_window_never_read_withholds_absent() { let report = run( &unread, &catalog(), - &numbering(NumberingMethod::Manual), + &NumberingDeclaration::new([("Receipt", NumberingMethod::Manual)]).expect("numbering"), &proposals, ); let entry = only(&report); @@ -2298,26 +2298,111 @@ fn the_same_proposal_is_absent_when_the_window_did_read_remote_ids() { #[test] fn unread_remote_id_outranks_resemblance_but_keeps_its_candidates() { let unread = BookWindow::observed( - "20260801", "20260831", WindowRead::Complete, RemoteIdEvidence::NotRead, + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::NotRead, vec![BookRow::new("book-1", "20260812", "AA0118").build()], - ).expect("window"); - let proposals = [ProposalRow::new(0, "20260812", "AA0777").remote_id("tally-1").build()]; - let report = run(&unread, &catalog(), &numbering(NumberingMethod::Automatic), &proposals); + ) + .expect("window"); + let proposals = [ProposalRow::new(0, "20260812", "AA0777") + .remote_id("tally-1") + .build()]; + let report = run( + &unread, + &catalog(), + &numbering(NumberingMethod::Automatic), + &proposals, + ); let entry = only(&report); assert_eq!(reason(entry), UndecidedReason::RemoteIdEvidenceUnavailable); - assert_eq!(entry.undecided().expect("undecided").candidates[0].book_key, "book-1"); + assert_eq!( + entry.undecided().expect("undecided").candidates[0].book_key, + "book-1" + ); assert_eq!(report.observations().unmatched_book_vouchers, 0); } +#[test] +fn unread_remote_id_outranks_nondecisive_number_candidates() { + let unread = BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::NotRead, + vec![BookRow::new("book-1", "20260812", "AA0118").build()], + ) + .expect("window"); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .remote_id("tally-1") + .build()]; + let report = run( + &unread, + &catalog(), + &numbering(NumberingMethod::Automatic), + &proposals, + ); + assert_eq!( + reason(only(&report)), + UndecidedReason::RemoteIdEvidenceUnavailable + ); + assert_eq!( + only(&report).undecided().unwrap().candidates[0].rule, + CandidateRule::SharedVoucherNumber + ); +} + +#[test] +fn unread_remote_id_outranks_unobserved_type_number_candidates() { + let unread = BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::NotRead, + vec![BookRow::new("book-1", "20260812", "AA0118").build()], + ) + .expect("window"); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .voucher_type("Receipt") + .remote_id("tally-1") + .build()]; + let report = run( + &unread, + &catalog(), + &NumberingDeclaration::new([("Receipt", NumberingMethod::Manual)]).expect("numbering"), + &proposals, + ); + assert_eq!( + reason(only(&report)), + UndecidedReason::RemoteIdEvidenceUnavailable + ); + assert_eq!( + only(&report).undecided().unwrap().candidates[0].rule, + CandidateRule::SharedVoucherNumber + ); +} + #[test] fn remote_identity_reports_an_exact_voucher_type_difference() { let window = window(&[BookRow::new("book-1", "20260812", "AA0118") - .voucher_type("Receipt").remote_id("tally-1")]); + .voucher_type("Receipt") + .remote_id("tally-1")]); let proposals = [ProposalRow::new(0, "20260812", "AA0118") - .voucher_type("Sales").remote_id("tally-1").build()]; - let report = run(&window, &catalog(), &numbering(NumberingMethod::Manual), &proposals); - let PresenceStatus::Present { differences, .. } = &only(&report).status else { panic!("present") }; - let difference = differences.iter().find(|item| item.field == DifferenceField::VoucherType) + .voucher_type("Sales") + .remote_id("tally-1") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("present") + }; + let difference = differences + .iter() + .find(|item| item.field == DifferenceField::VoucherType) .expect("type difference serialized"); assert_eq!(difference.proposed.as_deref(), Some("Sales")); assert_eq!(difference.observed.as_deref(), Some("Receipt")); From 17bfe9786bf48794b0f2a0a197042e8be29f5e0e Mon Sep 17 00:00:00 2001 From: t Date: Sun, 13 Sep 2026 04:14:14 +0530 Subject: [PATCH 84/91] fix: bind presence request schemas to compatibility evidence --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 10 +++++++--- .../bridge-tally-core/src/book_presence_tests.rs | 2 +- tools/bridge-tally-compatibility/src/lib.rs | 7 +++++-- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index ff770f14..6b00fd32 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": "e7fda0fd241edfdc4e4465b003d83b5b35a33bf5110c2cb6f731902c149bb490", + "compatibility_surface_sha256": "c7d3e0dba2066f7c86708625f66ab467330b9f90d6e13c673bae5d6ed65f7206", "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 19e9aed5..27797e96 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "61090585fe6a99c0caedd4e484281101aa3b2aeb46db30716bf3cfa245bd8b5b" + "sha256": "5669c7ebdbfc1324760cc741ec00d8433d9601f1d9a5a063f5a9715d0bee9f25" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -329,6 +329,10 @@ "path": "src-tauri/src/agent.rs", "sha256": "2132f9735c9ffe79262ad84a83c99c899fde4c37a809420877e5db83dcd3bcc4" }, + { + "path": "src-tauri/src/agent_catalog.rs", + "sha256": "bbbd925062d39bd4f0997f820ea5fe3da6974deb403f0b9eb15eaad74afcc094" + }, { "path": "src-tauri/src/agent_desktop_journal.rs", "sha256": "9922e27217b13f1834c2cd40e5f99a9ada32eae1be180dd46dd4ba209bb41ae6" @@ -827,7 +831,7 @@ }, { "path": "tools/bridge-tally-compatibility/src/lib.rs", - "sha256": "87659c2359470f3eb8b3cd2c2cc0baff17a573790a39cde4ae33abf316be5ed6" + "sha256": "993dfd51072f81d9f8b115d666de0d7830a10eec3a077d7fb690f6f206773b25" }, { "path": "tools/bridge-tally-compatibility/src/main.rs", @@ -862,5 +866,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "e7fda0fd241edfdc4e4465b003d83b5b35a33bf5110c2cb6f731902c149bb490" + "manifest_sha256": "c7d3e0dba2066f7c86708625f66ab467330b9f90d6e13c673bae5d6ed65f7206" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index da330589..71b7ea5e 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -2264,7 +2264,7 @@ fn a_remote_id_the_window_never_read_withholds_absent() { let report = run( &unread, &catalog(), - &NumberingDeclaration::new([("Receipt", NumberingMethod::Manual)]).expect("numbering"), + &numbering(NumberingMethod::Manual), &proposals, ); let entry = only(&report); diff --git a/tools/bridge-tally-compatibility/src/lib.rs b/tools/bridge-tally-compatibility/src/lib.rs index c0da5295..acb90a33 100644 --- a/tools/bridge-tally-compatibility/src/lib.rs +++ b/tools/bridge-tally-compatibility/src/lib.rs @@ -44,7 +44,9 @@ pub const RESERVED_SURFACE_FILES: usize = 15; /// the surface digest unchanged and let existing evidence attest behaviour it /// never covered. That is the deliberate decision the paragraph above requires, /// and it is one file for one named reason — not headroom. -pub const MAX_SURFACE_FILES: usize = 215; +/// The next slot binds `agent_catalog.rs`: its recursively executed proposal +/// schema changes presence admission, so existing receipts must cover its bytes. +pub const MAX_SURFACE_FILES: usize = 216; pub const MAX_OPERATIONS: usize = 16; pub const MAX_CLAIMS: usize = 128; pub const MAX_KEYS: usize = 32; @@ -62,7 +64,8 @@ const REQUIRED_SURFACE_DIRECTORIES: [&str; 2] = /// entry and resealing. A required path cannot be dropped silently, and /// `gate_rejects_each_omitted_required_lifecycle_path` iterates this list, so adding it /// here is what covers its omission. -const REQUIRED_SURFACE_FILES: [&str; 6] = [ +const REQUIRED_SURFACE_FILES: [&str; 7] = [ + "src-tauri/src/agent_catalog.rs", "src-tauri/src/agent_desktop_journal.rs", "src-tauri/src/agent_ledgers.rs", "src-tauri/src/source_draft/lifecycle.rs", From 7154923bd71230fc0b50e79735c8c808c503e71d Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal <57982425+lamemustafa@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:50:04 +0530 Subject: [PATCH 85/91] Degrade a partial window to possibly_present instead of refusing it (#397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BookWindow::observed` refused a `WindowRead::Partial` read outright (`PresenceError::WindowIncomplete`). That placement was right about the hazard and wrong about its blast radius: only `Absent` ever needed a complete window, but a construction-time refusal withheld `Present` and `PossiblyPresent` too. Since the adapter has no source-side control total for a nonempty window, every nonempty window is `Partial` — so the tool answered nothing at all over any range that held vouchers. Move the gate to where `Absent` is produced. `BookWindow` now retains its `WindowRead` and exposes `read()`; both states construct. `decide` checks it at the sole `PresenceStatus::Absent` site, last among the withholdings, and degrades a `Partial` window's would-be `Absent` to `PossiblyPresent(UndecidedReason::WindowNotProvenComplete)`. `Present` and `PossiblyPresent` are produced from a `Partial` window exactly as before. `agent_presence.rs` continues past a nonempty window instead of refusing early, and reports the window's real `read` state rather than a hardcoded "complete". `agent_catalog.rs`'s `voucher_presence` description states the new contract. That description names the reason a caller will actually read, which is not the one `safe_reason_code` returns. `UndecidedReason` serialises through `rename_all = "snake_case"`, so an item's `reason` field carries `window_not_proven_complete`; `presence_window_not_proven_complete` is the error-path spelling and appears nowhere in a successful response. The description advertises the serde form, and the schema test now asserts the anchored string and rejects the prefixed one, so the two cannot drift apart again without failing. Tests pin the invariant the type system used to buy: a `Partial` window withholds `absent` when nothing resembles a proposal, and the identical contents read `Complete` still issue it — the pair, so the first cannot pass because `Absent` broke generally. ADR 0017 is updated to match: the completeness gate is documented at `Absent`'s production rather than at construction, and the three Consequences passages asserting construction-time refusal now describe verdict-time degradation. Its measured analysis is unchanged — the source-side control total is still the only thing that would close nonempty qualification, and the widened re-read stays rejected. The ADR is not a pinned compatibility-surface file; the three touched Rust files are, and the surface and matrix are resealed here. Co-authored-by: t Co-authored-by: Claude Opus 5 --- docs/adr/0017-voucher-presence-authority.md | 92 ++++++++++++++----- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 10 +- .../bridge-tally-core/src/book_presence.rs | 63 +++++++++---- .../src/book_presence_tests.rs | 82 +++++++++++++++-- src-tauri/src/agent_catalog.rs | 2 +- src-tauri/src/agent_presence.rs | 27 +++--- src-tauri/src/agent_presence_tests.rs | 50 +++++++--- 8 files changed, 252 insertions(+), 76 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 284c6e57..b5948781 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -130,7 +130,12 @@ nothing, presented to an operator as a result. An empty candidate list means `master_binding` has to decide which of those it is reporting, and the two readings are one word apart in the output. -### 2. A window is a *claim about a window*, and it must be complete +One further withholding has a different shape from every condition above: it is +about the window rather than the proposal or its binding, and §2 states it. A +window not proven to have been read whole withholds `Absent` from every +proposal in it, however complete that proposal's own evidence was. + +### 2. A window is a *claim about a window*, and only `Absent` needs it complete `BookWindow::observed` is a boundary parse. It refuses, rather than degrades, on: @@ -144,19 +149,53 @@ on: would not have done: a status field does not neutralise the per-voucher verdict printed beside it, which is the defect this contract cites elsewhere and had reproduced here. -- **a read that was not complete** — `WindowIncomplete`. A window whose - emptiness was only partially corroborated is not "no match found". This is - the single most dangerous confusion available here, so it is a typed error - rather than a flag a caller may overlook. A window that could not be read at - all never reaches this constructor: the read fails, and the tool fails with - it. What this does **not** cover is named in the Consequences — a response - Tally answers short without saying so; - a window that does not **cover** every proposed date — `WindowDoesNotCover`. A voucher outside the window is invisible, so a verdict over it would be fiction; - a voucher dated outside the window's own range, a duplicate voucher key, an invalid range, or a window past its bound. +**Completeness of the read is not one of those refusals. It is a gate on +`Absent` alone, and it sits where `Absent` is produced.** A window declares the +`WindowRead` its source read reported — `Complete` or `Partial` — and retains +it; both construct, and `BookWindow::read()` carries the answer forward to +`decide`. The asymmetry is the point. `Present` and `PossiblyPresent` are +claims about rows that *were* read: an identity match names a row in hand, and +a resemblance names rows in hand, and neither is made stronger or weaker by +rows nobody saw. `Absent` is the only verdict that claims something about the +rows nobody saw — "not anywhere in this window" — so it is the only one that +needs the window read whole. A window whose emptiness was only partially +corroborated is not "no match found"; it is "no match found in the part that +was read", and conflating the two remains the single most dangerous confusion +available here. + +So `decide` checks `read()` at the one place `PresenceStatus::Absent` is +produced, and a `Partial` window degrades that verdict to +`PossiblyPresent(WindowNotProvenComplete)` rather than issuing it. That reason +has two spellings and they are not interchangeable: `safe_reason_code()` +returns `presence_window_not_proven_complete`, while the tool's per-item +`reason` field carries the serde spelling `window_not_proven_complete`. + +The check is **last** among the withholdings, after +`RemoteIdEvidenceUnavailable`, `ManualNumberNotSupplied` and the party +outcomes, so a proposal that already +withheld evidence of its own is reported under that more specific reason; +`WindowNotProvenComplete` is only ever reported when nothing else was missing +and the window itself is the sole reason the absence cannot be claimed. A +window that could not be read at all still never reaches the constructor: the +read fails, and the tool fails with it. What the gate does **not** cover is +named in the Consequences — a response Tally answers short without saying so. + +An earlier revision placed this gate at construction instead, refusing a +`Partial` read as `PresenceError::WindowIncomplete`. That was right about the +hazard and wrong about its blast radius: a construction-time refusal withholds +`Present` and `PossiblyPresent` too, and since the only window this adapter can +currently build over a nonempty range *is* `Partial` (see the Consequences), +the tool emitted no verdicts at all over such a range where it was entitled to +emit `present` and `possibly_present`. The rule did not change when the gate +moved — only `Absent` ever needed completeness, and only `Absent` is now +withheld for its absence. + Every verdict is therefore explicitly scoped to the window the report carries. `Absent` means *absent from this window* — it never means "absent from the book". A voucher keyed in September against an August window is not visible, @@ -204,7 +243,7 @@ Per proposed voucher, exactly one of: | --- | --- | --- | | `Present { book_key, basis, differences }` | An identity key matched, uniquely on both sides | excluding this voucher from the import | | `PossiblyPresent { reason, candidates, .. }` | Something resembles it, or something prevented a decision | **nothing** | -| `Absent` | No rule produced any candidate, in a window proven to cover it | including this voucher in the import | +| `Absent` | No rule produced any candidate, in a window proven to cover it **and** proven to have been read whole | including this voucher in the import | `PossiblyPresent` carries candidates labelled with the **rule that surfaced each** — `SharedRemoteId`, `SharedVoucherNumber`, @@ -440,9 +479,10 @@ human-approved batch — this ADR does not move. - `bridge_tally_core::book_presence` is new and is the only implementation. The MCP tool `voucher_presence` is its first consumer; it performs the existing - qualified ledger-catalogue and `vouchers` window reads, refuses to build a - window from a partial read, and shapes the report through the same party-name - marking and egress redaction as every other read result. + qualified ledger-catalogue and `vouchers` window reads, builds a window from + the read state it can actually prove — `Partial` over a nonempty range — and + shapes the report through the same party-name marking and egress redaction as + every other read result. - **Catalog coverage is byte-exact.** The typed boundary retains each observed ledger and party spelling separately from its folded resemblance key, and rejects a window whose exact spelling is absent from the catalog. A candidate @@ -511,12 +551,19 @@ human-approved batch — this ADR does not move. and would not have had one regardless. - The adapter requests the whole window before any comparison; `vouchers`' own pagination bounds output, not Tally's work. That request is not evidence that - a nonempty response is complete, so the presence adapter refuses it pending - the source-side control total below. A window past `MAX_WINDOW_VOUCHERS` is - refused with a narrow-the-range error rather than silently truncated. + a nonempty response is complete, so the presence adapter records such a window + as `Partial` pending the source-side control total below, and no `Absent` can + issue from it. A window past `MAX_WINDOW_VOUCHERS` is refused with a + narrow-the-range error rather than silently truncated. - **Nonempty window qualification is unavailable until the read has a source-side - control total.** A nonempty response is therefore represented as `Partial` - and refused at the `BookWindow` boundary; it cannot issue `Absent`. Three + control total.** A nonempty response is therefore represented as `Partial`. + It is still a window, and it still answers: `present` and `possibly_present` + are produced from it exactly as from a complete one, because neither needs + completeness. What it cannot issue is `Absent` — a proposal nothing in the + window resembled comes back `possibly_present` with reason + `window_not_proven_complete` instead. An empty window is the narrow + case the existing emptiness control can still corroborate `Complete`, and it + is therefore the only shape from which `absent` is reachable today. Three other ways a window read can go wrong are closed: a transport or source-limit failure never produces a window because the read itself fails; a malformed or short body fails the strict parse; and the paired read refuses a @@ -579,10 +626,13 @@ human-approved batch — this ADR does not move. - A prior owner-authorized, read-only replay exercised the decision rules using proposals built from observed rows. It did not establish source completeness, operational `Absent` capability, or a qualified nonempty window. The current - adapter therefore refuses a nonempty window as `presence_window_incomplete` before it - emits verdicts. The replay remains useful for controlled rule characterization - and for checking admissible perturbation seeds; it is not merge evidence for - a presence decision against a live company. + adapter therefore emits `present` and `possibly_present` verdicts from a + nonempty window but never `absent`: such a window is `Partial`, and the + verdict that would have been `absent` is reported as `possibly_present` with + reason `window_not_proven_complete`. The replay remains useful for + controlled rule characterization and for checking admissible perturbation + seeds; it is not merge evidence for a presence decision against a live + company. ## Alternatives rejected diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 6b00fd32..581bbc32 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": "c7d3e0dba2066f7c86708625f66ab467330b9f90d6e13c673bae5d6ed65f7206", + "compatibility_surface_sha256": "723a605dfecf20b4e5cbb69fb18515fa34906b02bbb56244b7619353a29075a4", "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 27797e96..5c312e99 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "5669c7ebdbfc1324760cc741ec00d8433d9601f1d9a5a063f5a9715d0bee9f25" + "sha256": "652f57e441b05f4bdb25c8e53e9bcd9000641491e2fb8479c0a6344764b2b54c" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -331,7 +331,7 @@ }, { "path": "src-tauri/src/agent_catalog.rs", - "sha256": "bbbd925062d39bd4f0997f820ea5fe3da6974deb403f0b9eb15eaad74afcc094" + "sha256": "a5e7065c0ec1a40dc3f1e08dd5ad412e067cb58fd50c85b906b0b66d7e99ffa7" }, { "path": "src-tauri/src/agent_desktop_journal.rs", @@ -347,11 +347,11 @@ }, { "path": "src-tauri/src/agent_presence.rs", - "sha256": "8c21bf41b456fcc9d748f6f80e6fc08446c3810375df21a508efc1e0b2a36414" + "sha256": "040ea6c18ddd73856298d5862b6bac3f8bc805a748d0a404e76543d39fe2f30d" }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "0367c9a91f921311f16ba6ca1a7ee80572ac59c46b0ff99b0edea70c757b5da7" + "sha256": "6b8360c785405eb34168d8a4666684bb36f32fb62a4309e0786fc5958e0d08f8" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -866,5 +866,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "c7d3e0dba2066f7c86708625f66ab467330b9f90d6e13c673bae5d6ed65f7206" + "manifest_sha256": "723a605dfecf20b4e5cbb69fb18515fa34906b02bbb56244b7619353a29075a4" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index b573aab5..85817224 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -105,12 +105,6 @@ pub const MAX_TEXT_CHARS: usize = 16_384; /// before any comparison ran. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum PresenceError { - /// The window came from a read that was not complete. A window too dense - /// to read, or one whose emptiness was only partly corroborated, is not - /// "no match found" — and this is the confusion most likely to turn into a - /// duplicated invoice, so it is a type error rather than a flag. - #[error("book window was not read completely")] - WindowIncomplete, #[error("book window range was invalid")] WindowRangeInvalid, #[error("book window exceeded its bound")] @@ -187,7 +181,6 @@ impl PresenceError { /// A stable code safe to surface to an operator or a tool result. pub fn safe_reason_code(&self) -> &'static str { match self { - Self::WindowIncomplete => "presence_window_incomplete", Self::WindowRangeInvalid => "presence_window_range_invalid", Self::WindowTooLarge => "presence_window_too_large", Self::WindowLedgerMembershipsTooMany => "presence_window_ledger_memberships_too_many", @@ -238,9 +231,14 @@ pub enum RemoteIdEvidence { NotRead, } -/// How completely the window's source read observed its range. Only a complete -/// read may become a `BookWindow`; the other value exists so a caller must -/// state which it has rather than omit the question. +/// How completely the window's source read observed its range. Both values +/// become a `BookWindow` — a caller must state which it has rather than omit +/// the question — but only `Complete` may license `PresenceStatus::Absent`. +/// A window too dense to read, or one whose emptiness was only partly +/// corroborated, is not "no match found", and treating it as one is the +/// confusion most likely to turn into a duplicated invoice: `decide` degrades +/// a `Partial` window's would-be `Absent` to +/// `UndecidedReason::WindowNotProvenComplete` instead. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum WindowRead { @@ -589,13 +587,15 @@ impl ProposedBatch { } } -/// One observed window of a company's book. It can only be constructed from a -/// read that observed its whole range, so "the window was too dense to read" -/// can never reach a comparison as "nothing matched". +/// One observed window of a company's book. A window whose read was only +/// `Partial` is still admitted: `Present` and `PossiblyPresent` need no +/// completeness proof, only `Absent` does, and that gate lives at verdict +/// production (`assess`/`decide`), keyed off `read()`, rather than here. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BookWindow { from: TallyDate, to: TallyDate, + read: WindowRead, remote_id_evidence: RemoteIdEvidence, vouchers: Vec, } @@ -720,9 +720,6 @@ impl BookWindow { remote_id_evidence: RemoteIdEvidence, vouchers: Vec, ) -> Result { - if read != WindowRead::Complete { - return Err(PresenceError::WindowIncomplete); - } let from = TallyDate::parse(from.to_string()).map_err(|_| PresenceError::DateInvalid)?; let to = TallyDate::parse(to.to_string()).map_err(|_| PresenceError::DateInvalid)?; if from.as_str() > to.as_str() { @@ -771,6 +768,7 @@ impl BookWindow { Ok(Self { from, to, + read, remote_id_evidence, vouchers, }) @@ -788,6 +786,12 @@ impl BookWindow { &self.vouchers } + /// Whether this window's source read observed its whole range. Only + /// `Complete` may license `PresenceStatus::Absent`; see `decide`. + pub fn read(&self) -> WindowRead { + self.read + } + pub fn remote_id_evidence(&self) -> RemoteIdEvidence { self.remote_id_evidence } @@ -964,6 +968,13 @@ pub enum UndecidedReason { /// strongest key available to this proposal was never compared. An /// `Absent` here would rest on evidence that was not gathered. RemoteIdEvidenceUnavailable, + /// Nothing resembled the proposal, and every other decisive key was + /// either absent or already compared — but the window's own read was + /// only `Partial`. `Absent` means "not anywhere in this window", and + /// that claim is unavailable from a window not proven to cover its whole + /// declared range: what looks like "no match found" may only be "no + /// match found in the part that was read". + WindowNotProvenComplete, } impl UndecidedReason { @@ -983,6 +994,7 @@ impl UndecidedReason { Self::BookVoucherClaimedTwice => "presence_book_voucher_claimed_twice", Self::IdentityConflict => "presence_identity_conflict", Self::RemoteIdEvidenceUnavailable => "presence_remote_id_evidence_unavailable", + Self::WindowNotProvenComplete => "presence_window_not_proven_complete", } } } @@ -1044,8 +1056,11 @@ pub enum PresenceStatus { /// Something resembles it, or something prevented a decision. Authorises /// nothing. PossiblyPresent(Undecided), - /// No rule produced any candidate, in a window proven to cover it. - /// `Absent` is always relative to that window. + /// No rule produced any candidate, in a window proven to cover the + /// proposal's date **and** proven to have been read completely + /// (`WindowRead::Complete`). `Absent` is always relative to that window. + /// A window read only `Partial` degrades this to `PossiblyPresent( + /// UndecidedReason::WindowNotProvenComplete)` instead — see `decide`. Absent, } @@ -1846,6 +1861,18 @@ fn decide( BTreeSet::new(), ); } + // Every other decisive key was either absent or already compared — + // but `Absent` claims "not anywhere in this window", and that claim + // is only sound when the window's own read covered its whole range. + if window.read() != WindowRead::Complete { + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::WindowNotProvenComplete, + (Vec::new(), 0), + )), + BTreeSet::new(), + ); + } return shell(PresenceStatus::Absent, BTreeSet::new()); } diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 71b7ea5e..c5d5a7f9 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -233,18 +233,22 @@ fn reason(entry: &VoucherPresence) -> UndecidedReason { // --- the window is a claim about a window ------------------------------ +/// A window's completeness gate moved from construction to verdict +/// production (see `PresenceStatus::Absent`'s doc comment and `decide`): a +/// `Partial` read is still a legal `BookWindow`, and it retains its own +/// `read()` rather than having it checked once and discarded, because +/// `decide` needs it every time it would otherwise settle `Absent`. #[test] -fn a_partial_read_can_never_become_a_window() { - let error = BookWindow::observed( +fn a_partial_read_can_become_a_window_that_remembers_it_was_partial() { + let window = BookWindow::observed( "20260801", "20260831", WindowRead::Partial, RemoteIdEvidence::Observed, Vec::new(), ) - .expect_err("a partial read is not a window"); - assert_eq!(error, PresenceError::WindowIncomplete); - assert_eq!(error.safe_reason_code(), "presence_window_incomplete"); + .expect("a partial read is still a window"); + assert_eq!(window.read(), WindowRead::Partial); } #[test] @@ -1814,7 +1818,6 @@ fn a_window_bounds_aggregate_ledger_key_bytes_before_indexing() { #[test] fn every_error_carries_a_distinct_stable_reason_code() { let codes = [ - PresenceError::WindowIncomplete, PresenceError::WindowRangeInvalid, PresenceError::WindowTooLarge, PresenceError::WindowLedgerMembershipsTooMany, @@ -1837,7 +1840,7 @@ fn every_error_carries_a_distinct_stable_reason_code() { .iter() .map(PresenceError::safe_reason_code) .collect::>(); - assert_eq!(codes.len(), 19); + assert_eq!(codes.len(), 18); assert!(codes.iter().all(|code| code.starts_with("presence_"))); } @@ -2295,6 +2298,71 @@ fn the_same_proposal_is_absent_when_the_window_did_read_remote_ids() { assert!(only(&report).is_absent()); } +// --- a window not proven complete is not a window that found nothing ---- +// +// This is the safety invariant the construction-time refusal used to buy: +// `PresenceStatus::Absent` must be unreachable from a window whose `read` is +// `Partial`. The gate moved to `decide` (see `PresenceStatus::Absent`'s doc +// comment), so it is proven here instead of by the type system refusing to +// build the window at all. + +#[test] +fn a_partial_window_withholds_absent_even_when_nothing_resembles_the_proposal() { + let partial = BookWindow::observed( + "20260801", + "20260831", + WindowRead::Partial, + RemoteIdEvidence::Observed, + vec![BookRow::new("book-1", "20260819", "AA0130") + .party("Bravo Industries") + .build()], + ) + .expect("a partial read is still a window"); + let proposals = [ProposalRow::new(0, "20260812", "AA0777") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-55.00"], + ["Sales Account", "55.00"], + ]) + .build()]; + let report = run( + &partial, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!( + !entry.is_absent(), + "the window's own read never covered its whole declared range" + ); + assert_eq!(reason(entry), UndecidedReason::WindowNotProvenComplete); +} + +/// The mirror of the test above: identical window contents and an identical +/// proposal, differing only in `WindowRead`. Without this pair, the first +/// test could pass for the wrong reason -- because `Absent` had broken +/// generally, not because `Partial` specifically withholds it. +#[test] +fn the_same_proposal_is_absent_against_the_same_contents_read_completely() { + let complete = + window(&[BookRow::new("book-1", "20260819", "AA0130").party("Bravo Industries")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0777") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-55.00"], + ["Sales Account", "55.00"], + ]) + .build()]; + let report = run( + &complete, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert!(only(&report).is_absent()); +} + #[test] fn unread_remote_id_outranks_resemblance_but_keeps_its_candidates() { let unread = BookWindow::observed( diff --git a/src-tauri/src/agent_catalog.rs b/src-tauri/src/agent_catalog.rs index de241df7..280801f1 100644 --- a/src-tauri/src/agent_catalog.rs +++ b/src-tauri/src/agent_catalog.rs @@ -308,7 +308,7 @@ pub(super) fn registered_tool_definitions(import_enabled: bool, writes_enabled: json!({"type":"object","additionalProperties":false,"required":["company_guid","from","to"],"properties":{"company_guid":{"type":"string","minLength":1},"from":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"},"to":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"},"voucher_type":{"type":"string","maxLength":agent_import::MAX_MASTER_NAME_CHARS},"ledger":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"},"offset":{"type":"integer","minimum":0,"default":0},"limit":{"type":"integer","minimum":1,"default":500}}}), ), "voucher_presence" => ( - "For a qualified complete window, answer which of 1\u{2013}500 proposed vouchers are already in the book. At present, the adapter has no source-completeness evidence for a nonempty window, so it refuses one as `presence_window_incomplete` and emits no operational presence verdict. `presence` is present, possibly_present or absent, and only `present` names a book voucher. The conditional decision basis can use a voucher number on a voucher type you declare `manual` \u{2014} unique on both sides, within an observed voucher type, and never onto a cancelled or optional voucher. It neither accepts nor reads client remote identifiers. Date, party and amount only ever produce candidates, with the rule that surfaced each and no ranking or score. Every voucher type a proposal names needs a declared numbering method; under `automatic` Tally discards the supplied number, so nothing can be decided from it. `absent` means absent from this window, so cover the dates the book could hold. Reads the full window before comparing; dense windows can fail source limits. Party names bind through the same rules as validate_masters. A reported difference on a `present` voucher is a finding for a person, not a work item: correcting a voucher by Alter or Cancel silently creates a duplicate instead (\u{00a7}9.7), and no Bridge path can correct a voucher it did not write. This never dispatches import XML to Tally.", + "Answer which of 1\u{2013}500 proposed vouchers are already in the book. `presence` is present, possibly_present or absent, and only `present` names a book voucher. The adapter has no source-completeness evidence for a nonempty window, so a nonempty window is read as `partial`; an empty window can still be corroborated complete. `present` and `possibly_present` never need a complete window and are produced either way, but `absent` means absent from the *whole* window and is only ever produced from one proven complete — a proposal that would otherwise be absent from a merely `partial` window instead comes back `possibly_present` with reason `window_not_proven_complete`. The conditional decision basis can use a voucher number on a voucher type you declare `manual` \u{2014} unique on both sides, within an observed voucher type, and never onto a cancelled or optional voucher. It neither accepts nor reads client remote identifiers. Date, party and amount only ever produce candidates, with the rule that surfaced each and no ranking or score. Every voucher type a proposal names needs a declared numbering method; under `automatic` Tally discards the supplied number, so nothing can be decided from it. `absent` means absent from this window, so cover the dates the book could hold. Reads the full window before comparing; dense windows can fail source limits. Party names bind through the same rules as validate_masters. A reported difference on a `present` voucher is a finding for a person, not a work item: correcting a voucher by Alter or Cancel silently creates a duplicate instead (\u{00a7}9.7), and no Bridge path can correct a voucher it did not write. This never dispatches import XML to Tally.", json!({"type":"object","additionalProperties":false,"required":["company_guid","from","to","numbering","vouchers"],"properties":{ "company_guid":{"type":"string","minLength":1}, "offset":{"type":"integer","minimum":0,"default":0}, diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs index 53c44f80..59b29a32 100644 --- a/src-tauri/src/agent_presence.rs +++ b/src-tauri/src/agent_presence.rs @@ -167,15 +167,12 @@ impl Server { evidence.state = "partial"; evidence.reason_code = reason.map(str::to_string); // The adapter has no source-side cardinality for nonempty - // windows. A later catalogue reread cannot change the fixed - // `Partial` state into a complete observation, so avoid the - // extra endpoint load and fail with the evidence already in - // hand. A future qualified nonempty path can continue to the - // paired-snapshot checks below. - return Err(PresenceError::WindowIncomplete - .safe_reason_code() - .to_string() - .into()); + // windows, so `read` stays `Partial` (its default above) and + // this window can never license `Absent` (`book_presence` + // degrades that to `WindowNotProvenComplete` instead of + // refusing it). `Present` and `PossiblyPresent` need no + // completeness proof, so the read continues to the + // paired-snapshot checks below rather than refusing outright. } // The verdict is built from two independently timed observations, @@ -213,6 +210,7 @@ impl Server { let (result, truncated) = presence_result( &report, &catalogue, + read, reason, offset, limit, @@ -463,6 +461,7 @@ fn bounded_observations(mut book: Value, budget: usize) -> Value { fn presence_result( report: &PresenceReport, catalogue: &[String], + read: WindowRead, corroboration_reason: Option<&'static str>, offset: usize, limit: usize, @@ -470,6 +469,10 @@ fn presence_result( ) -> (Value, bool) { let (from, to) = report.window(); let total = report.vouchers().len(); + let read_label = match read { + WindowRead::Complete => "complete", + WindowRead::Partial => "partial", + }; // Paged like every other read in this adapter, for one reason beyond // consistency: this result shape is otherwise invisible to `page_shape`, // so an over-large report would be discarded wholesale *after* all three @@ -487,8 +490,10 @@ fn presence_result( let mut result = json!({ "profile": "agent_voucher_presence_v1", // Every verdict is relative to this window. `absent` means absent from - // this range and never absent from the book. - "window": {"from": from, "to": to, "read": "complete", "reason": corroboration_reason}, + // this range and never absent from the book, and it is only ever + // produced when `read` here is "complete" -- a "partial" window still + // yields `present`/`possibly_present`, just never `absent`. + "window": {"from": from, "to": to, "read": read_label, "reason": corroboration_reason}, "items": items, "offset": offset, "total": total, diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index 949a03da..a02462f7 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -162,7 +162,11 @@ fn the_published_schema_names_the_three_numbering_methods_and_its_bounds() { assert!(tool.get("annotations").is_none()); let description = tool["description"].as_str().expect("tool description"); assert!(description.contains("manual")); - assert!(description.contains("presence_window_incomplete")); + // The spelling matters: `safe_reason_code` returns the `presence_`-prefixed + // form, but a caller reads the serde one off an item's `reason` field, and a + // description advertising the wrong one is a string no caller can ever match. + assert!(description.contains("reason `window_not_proven_complete`")); + assert!(!description.contains("presence_window_not_proven_complete")); assert!(!description.contains("REMOTEID")); } @@ -257,7 +261,15 @@ fn a_caller_limited_presence_page_includes_its_resume_cursor() { PresenceRequest::new(&window, &catalog, &numbering, &proposals).expect("presence request"); let report = book_presence::assess(&request); - let (result, truncated) = presence_result(&report, &catalogue, None, 0, 1, 200_000); + let (result, truncated) = presence_result( + &report, + &catalogue, + WindowRead::Complete, + None, + 0, + 1, + 200_000, + ); assert!(truncated); assert_eq!(result["offset"], 0); assert_eq!(result["total"], 2); @@ -575,16 +587,17 @@ fn plans(steps: Vec) -> Vec { fn presence_plans() -> Vec { let catalogue = catalogue_xml(); let mut steps = vec![Step::Company, Step::Status, Step::Company, Step::Status]; - // Catalogue, then the voucher window. A nonempty window lacks a - // source-side cardinality control and is refused before a paired - // catalogue snapshot could contribute to a verdict. + // Catalogue, then the voucher window, then the paired-snapshot catalogue + // reread the nonempty (necessarily `Partial`) window path takes before it + // can still produce `present`/`possibly_present` verdicts. steps.extend(paired_read(&catalogue)); steps.extend(paired_read(&window_xml())); + steps.extend(paired_read(&catalogue)); plans(steps) } #[tokio::test] -async fn a_nonempty_window_without_a_control_total_refuses_to_issue_absent() { +async fn a_nonempty_window_without_a_control_total_still_answers_but_never_issues_absent() { let simulator = SequenceSimulator::spawn(presence_plans()).expect("simulator"); let directory = tempfile::tempdir().expect("directory"); let server = Server::new(Settings { @@ -618,19 +631,32 @@ async fn a_nonempty_window_without_a_control_total_refuses_to_issue_absent() { }), ) .await; - // A nonempty response has no source-side cardinality control. It therefore - // cannot issue the `Absent` verdict this fixture used to assert. - assert_eq!(response["isError"], true, "{response}"); + // A nonempty response has no source-side cardinality control, so the + // window is `Partial` and can never license `Absent` -- but `Present` and + // `PossiblyPresent` need no completeness proof, so the tool still answers + // rather than refusing the whole request the way it used to. + assert_eq!(response["isError"], false, "{response}"); + let result = &response["structuredContent"]["result"]; + assert_eq!(result["window"]["read"], "partial"); assert_eq!( - response["structuredContent"]["result"]["error"]["code"], - "presence_window_incomplete" + result["totals"], + json!({"requested": 3, "present": 2, "possibly_present": 1, "absent": 0}) + ); + let items = result["items"].as_array().expect("items"); + assert_eq!(items[0]["presence"], "present", "{items:?}"); + assert_eq!(items[1]["presence"], "present", "{items:?}"); + assert_eq!(items[2]["presence"], "possibly_present", "{items:?}"); + assert_eq!( + items[2]["reason"], "window_not_proven_complete", + "nothing resembled JV-9, but the window that found nothing was never \ + proven complete, so it must not be reported absent" ); assert_eq!( response["structuredContent"]["evidence"]["state"], "partial" ); let observed = simulator.finish().expect("requests"); - assert_eq!(observed.len(), 16); + assert_eq!(observed.len(), 22); } /// The admission contract this tool enforces lives in `agent_catalog.rs`, and From 30aa04bc1bba522b551be807f99ad03af82b8f35 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 15 Sep 2026 21:01:19 +0530 Subject: [PATCH 86/91] Update the live replay to the post-#397 presence contract replay_the_twenty_invoice_engagement still asserted that a nonempty window fails closed with presence_window_incomplete and evidence state partial. #397 deleted that error variant and made a partial window answer, withholding only Absent, so the assertion named a string the tool can no longer produce. It compiled because the code is a string literal rather than the enum, and it never failed because the test is #[ignore]d for the lab. An ignored test cannot fail, so it rots without telling anyone -- the same shape as the tool description that advertised the prefixed reason code, caught in review on #397, and the second instance of it in the same change. The assertions now mirror the offline equivalent: the window answers, its read is partial, totals.absent is zero, and no item may come back absent, because absence is the only verdict that needs to have seen the whole range. Nothing in the tree references presence_window_incomplete any more. Not run against the lab: the replay needs BRIDGE_TALLY_LIVE_* set, the gateway free, and owner authorization. The assertions are corrected but unexercised. Co-Authored-By: Claude Opus 5 --- src-tauri/src/agent_presence_tests.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index a02462f7..d4a70143 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -1215,14 +1215,28 @@ async fn replay_the_twenty_invoice_engagement() { "numbering": numbering, "vouchers": proposals}), ) .await; + // A nonempty window still cannot be proven complete -- no source-side + // cardinality control exists -- but that now withholds exactly one verdict + // instead of refusing the request. The window answers; nothing in it may + // come back `absent`, because absence is the only claim that needs to have + // seen the whole range. assert_eq!( - response["isError"], true, - "the nonempty window must fail closed" + response["isError"], false, + "a nonempty window must answer rather than refuse: {response}" ); + let result = &response["structuredContent"]["result"]; + assert_eq!(result["window"]["read"], "partial"); assert_eq!( - response["structuredContent"]["result"]["error"]["code"], - "presence_window_incomplete" + result["totals"]["absent"], 0, + "no proposal may be reported absent from a window that was never \ + proven complete: {result}" ); + for item in result["items"].as_array().expect("items") { + assert_ne!( + item["presence"], "absent", + "absent requires a proven-complete window: {item}" + ); + } assert_eq!( response["structuredContent"]["evidence"]["state"], "partial" From 1a62ea91782b72ba01304d7543aff7b5b0cabcf7 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 15 Sep 2026 21:16:39 +0530 Subject: [PATCH 87/91] Bind the party-difference test to a spelling that still binds 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 --- .../crates/bridge-tally-core/src/book_presence_tests.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index c5d5a7f9..5f054ec9 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -788,8 +788,12 @@ fn a_present_voucher_reports_a_party_the_book_disagrees_with() { fn a_party_difference_echoes_the_source_spelling_not_its_catalog_binding() { let window = window(&[BookRow::new("book-1", "20260812", "AA0118").party_field("Bravo Industries")]); + // The proposed spelling must bind, because only a bound party can disagree. + // Since #331 removed BindingBasis::NormalizedName, a case-folded spelling no + // longer binds, so this uses the catalogue's exact name -- the difference + // being asserted is proposed-vs-observed, not proposed-vs-its-own-binding. let proposals = [ProposalRow::new(0, "20260812", "AA0118") - .party("alpha traders") + .party("Alpha Traders") .build()]; let report = run( &window, @@ -804,7 +808,7 @@ fn a_party_difference_echoes_the_source_spelling_not_its_catalog_binding() { .iter() .find(|difference| difference.field == DifferenceField::Party) .expect("party difference"); - assert_eq!(party.proposed.as_deref(), Some("alpha traders")); + assert_eq!(party.proposed.as_deref(), Some("Alpha Traders")); assert_eq!(party.observed.as_deref(), Some("Bravo Industries")); } From 78e22373f3cd15a4a6125af7607c18f92befe0f8 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 15 Sep 2026 21:28:36 +0530 Subject: [PATCH 88/91] Let the live presence replay scope its read to one voucher type replay_the_twenty_invoice_engagement needs sixteen usable vouchers whose types are all manually numbered, because presence by voucher number is only meaningful under Manual -- under Automatic Tally discards the supplied number. But the read passed only company_guid, from and to, then took the first sixteen rows of whatever came back. A real book cannot satisfy that. Measured offline across twelve monthly captures of the reference book: every month holds roughly 150 automatic vouchers against 50 manual ones, so the first sixteen usable rows always include an automatic type and the manual-numbering precondition trips. So this replay could never have run against a real book, which is why the open P1 asking for live evidence was never answered: the mechanism for obtaining that evidence had itself never been exercised. A test that cannot run is indistinguishable from one nobody has tried. BRIDGE_PRESENCE_LIVE_VOUCHER_TYPE now scopes the read when set. Narrowing to a declared manual type is what the test already means rather than a way around its assertion. Running it this way reaches the presence call for the first time, and the call refuses with ledger_export_invalid -- a ledger-catalogue read failure, not a presence-window one. ledger_masters reads the same book's 864 ledgers cleanly on the same binary, so the divergence is in the presence engine's own catalogue path. Handed to the read-path lane; not diagnosed here. Co-Authored-By: Claude Opus 5 --- src-tauri/src/agent_presence_tests.rs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index d4a70143..3c989bab 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -1039,12 +1039,20 @@ async fn replay_the_twenty_invoice_engagement() { writes_enabled: false, }); - let read = server - .call_tool( - "vouchers", - json!({"company_guid": guid, "from": from, "to": to}), - ) - .await; + // Scope the read to a manually numbered type. Presence by voucher number is + // only meaningful under `Manual` -- under `Automatic` Tally discards the + // supplied number -- and a real book is overwhelmingly automatic: every + // month of the reference book holds ~150 automatic vouchers against ~50 + // manual ones. Reading the whole window and taking the first rows therefore + // picks up automatic types and trips the manual-numbering precondition + // below, which is why this replay had never been runnable against a real + // book. One declared type per run keeps the selection honest. + let selected_type = std::env::var("BRIDGE_PRESENCE_LIVE_VOUCHER_TYPE").ok(); + let mut request = json!({"company_guid": guid, "from": from, "to": to}); + if let Some(kind) = selected_type.as_deref() { + request["voucher_type"] = json!(kind); + } + let read = server.call_tool("vouchers", request).await; assert_eq!(read["isError"], false, "the window read failed"); let rows = read["structuredContent"]["result"]["items"] .as_array() From 64b8666b032a51f2e8dc2176af0ff8502992687a Mon Sep 17 00:00:00 2001 From: t Date: Tue, 15 Sep 2026 22:05:59 +0530 Subject: [PATCH 89/91] Restore the party-difference test's subject, and reseal The test asserts that a reported difference echoes the source's spelling rather than the master it bound to. Repairing it after "Rectify unqualified folded ledger binding" removed the folded basis, I used the catalogue's exact name -- which makes the two sides the same string and so is the one fixture under which the assertion cannot decide between those behaviours. It also made the test byte-equivalent to a_party_difference_compares_the_observed_party_field_not_every_ledger. This takes the #308 lane's version instead: bind on an identifier embedded in the master name, the remaining basis that decides without byte equality, so the source spells it its own way and still binds. Driven to failure two ways, restored byte-identical after each: - master name without the identifier -> fails at expect("party difference") - `proposed: Some(label(catalog_name))` in book_presence.rs, i.e. echoing the binding -> left "Alpha Traders 9876543210", right "ALPHA 9876543210" The second is the behaviour the name denies, and the previous fixture passed it unchanged. Also reseals the compatibility surface. The branch head failed `scripts/reseal.sh --verify` before this change -- confirmed against a clean 78e22373 with the exit status read directly rather than through a pipe, and not caused by this edit, which touches only the unpinned tests file. Pins 216 before and after, compared as sets rather than by count: none added or dropped. Gate: 1497 passed, 0 failed, 1 ignored; clippy silent on both workspaces; reseal --verify exits 0. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 4 ++-- .../src/book_presence_tests.rs | 18 +++++++++++------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 42571c7a..4b1c4df0 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": "2d6598742fd27c9bc472efeb2078aa2981a2c972a6e85a9fc2e5699ab6c7990e", + "compatibility_surface_sha256": "5900ba5282a7771c0cc7f5e894877a43a979d86e8b52045df9cc2a3787b4877d", "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 c2b98973..87e01ee8 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -351,7 +351,7 @@ }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "6b8360c785405eb34168d8a4666684bb36f32fb62a4309e0786fc5958e0d08f8" + "sha256": "176d9e0fe32b597b786cf207afa2f864bdfcb0a3d22e9fd80e7c3cf90c05bca7" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -866,5 +866,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "2d6598742fd27c9bc472efeb2078aa2981a2c972a6e85a9fc2e5699ab6c7990e" + "manifest_sha256": "5900ba5282a7771c0cc7f5e894877a43a979d86e8b52045df9cc2a3787b4877d" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 5f054ec9..edd9409b 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -786,18 +786,22 @@ fn a_present_voucher_reports_a_party_the_book_disagrees_with() { #[test] fn a_party_difference_echoes_the_source_spelling_not_its_catalog_binding() { + // This test needs a party that binds decisively while being spelled + // differently from the master it binds to -- otherwise there is nothing to + // echo and the assertion is vacuous. A case fold used to serve, and since + // "Rectify unqualified folded ledger binding" it does not: a folded name + // suggests candidates and no longer resolves. An identifier embedded in the + // master name is the remaining basis that decides without byte equality, + // so the source spells the identifier its own way and binds anyway. + let catalog = catalog_of(&["Alpha Traders 9876543210"]); let window = window(&[BookRow::new("book-1", "20260812", "AA0118").party_field("Bravo Industries")]); - // The proposed spelling must bind, because only a bound party can disagree. - // Since #331 removed BindingBasis::NormalizedName, a case-folded spelling no - // longer binds, so this uses the catalogue's exact name -- the difference - // being asserted is proposed-vs-observed, not proposed-vs-its-own-binding. let proposals = [ProposalRow::new(0, "20260812", "AA0118") - .party("Alpha Traders") + .party("ALPHA 9876543210") .build()]; let report = run( &window, - &catalog(), + &catalog, &numbering(NumberingMethod::Manual), &proposals, ); @@ -808,7 +812,7 @@ fn a_party_difference_echoes_the_source_spelling_not_its_catalog_binding() { .iter() .find(|difference| difference.field == DifferenceField::Party) .expect("party difference"); - assert_eq!(party.proposed.as_deref(), Some("Alpha Traders")); + assert_eq!(party.proposed.as_deref(), Some("ALPHA 9876543210")); assert_eq!(party.observed.as_deref(), Some("Bravo Industries")); } From f70c2b3e20e4f2c8fc4ff9a9e5fff78f8224e74d Mon Sep 17 00:00:00 2001 From: t Date: Tue, 15 Sep 2026 23:12:32 +0530 Subject: [PATCH 90/91] Read an absent party as absent, not as a party named "" The window JSON spells an absent string as `""` rather than omitting the key, so `row["party"].as_str()` yields `Some("")` -- which claims the voucher *has* a party whose name is blank. Two guards then fired on a book that merely held a voucher naming nobody: the catalogue check reported all 864 masters incomplete, and the presence engine refused the whole window with `presence_text_blank`. `present_text` translates that back at the boundary where it is introduced, so neither guard needs a special case. Only the **observed** side uses it. A blank on a caller's proposal is input the schema already refuses, and mapping it to "absent" here would quietly accept what the schema rejects -- so the two proposal sites keep reading the field literally. An empty `PARTYLEDGERNAME` is a real Tally shape rather than something we invent: 277 of 2,829 party fields across 53 captured windows of the reference book are genuinely empty, none absent. That is the opposite of an entry's ledger name, which is populated 7,633 times and blank zero times -- measured by the read-path session, and the reason this fix reads a blank party as "no party" instead of teaching the engine to carry blanks generally. The replay assertion now prints the refusal it failed on. Asserting `isError` alone reported that the read failed while withholding the one thing that says why, which cost a full diagnosis round the first time it fired. With the catalogue half in #400, the live replay `replay_the_twenty_invoice_engagement` passes against the real book for the first time: 20 vouchers, presence verdicts, no refusal. Gate: 1498 passed, 0 failed; clippy silent on both workspaces; reseal --verify exits 0 with the pin set unchanged at 216. Co-Authored-By: Claude Opus 5 --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 6 +-- src-tauri/src/agent_presence.rs | 28 +++++++++-- src-tauri/src/agent_presence_tests.rs | 46 ++++++++++++++++++- 4 files changed, 72 insertions(+), 10 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 4b1c4df0..e91d505f 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": "5900ba5282a7771c0cc7f5e894877a43a979d86e8b52045df9cc2a3787b4877d", + "compatibility_surface_sha256": "5cba801d6ae030e1ca8f83907db1dd429246688ce82b64370ca31ea03e24bd5c", "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 87e01ee8..58065be1 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -347,11 +347,11 @@ }, { "path": "src-tauri/src/agent_presence.rs", - "sha256": "040ea6c18ddd73856298d5862b6bac3f8bc805a748d0a404e76543d39fe2f30d" + "sha256": "44fa9f2482f1c1a76249bccdfa0b5e2626d816e9a405b721e04c8ef7da9d8ec5" }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "176d9e0fe32b597b786cf207afa2f864bdfcb0a3d22e9fd80e7c3cf90c05bca7" + "sha256": "a96ebf07de0b998056c682c76cf7df734c1eaeb00a535410a228c1805269c899" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -866,5 +866,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "5900ba5282a7771c0cc7f5e894877a43a979d86e8b52045df9cc2a3787b4877d" + "manifest_sha256": "5cba801d6ae030e1ca8f83907db1dd429246688ce82b64370ca31ea03e24bd5c" } \ No newline at end of file diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs index 59b29a32..f6d164e2 100644 --- a/src-tauri/src/agent_presence.rs +++ b/src-tauri/src/agent_presence.rs @@ -134,7 +134,10 @@ impl Server { .unwrap_or_default() .iter() .filter_map(|entry| entry["ledger"].as_str()); - for ledger in row["party"].as_str().into_iter().chain(entry_ledgers) { + for ledger in present_text(&row["party"]) + .into_iter() + .chain(entry_ledgers.filter(|name| !name.trim().is_empty())) + { if catalog.exact(ledger).is_none() { return Err("ledger_catalogue_incomplete".to_string().into()); } @@ -269,9 +272,9 @@ fn book_window( row["guid"].as_str().unwrap_or_default(), row["date"].as_str().unwrap_or_default(), row["voucher_type"].as_str().unwrap_or_default(), - row["voucher_number"].as_str(), + present_text(&row["voucher_number"]), None, - row["party"].as_str(), + present_text(&row["party"]), raw.iter().map(|entry| { ( entry["ledger"].as_str().unwrap_or_default(), @@ -301,9 +304,9 @@ fn book_window( key: row["guid"].as_str().unwrap_or_default(), date: row["date"].as_str().unwrap_or_default(), voucher_type: row["voucher_type"].as_str().unwrap_or_default(), - voucher_number: row["voucher_number"].as_str(), + voucher_number: present_text(&row["voucher_number"]), remote_id: None, - party: row["party"].as_str(), + party: present_text(&row["party"]), entries, cancelled: row["cancelled"].as_bool().unwrap_or_default(), optional: row["optional"].as_bool().unwrap_or_default(), @@ -311,6 +314,21 @@ fn book_window( BookWindow::from_observations(from, to, read, RemoteIdEvidence::NotRead, observations) } +/// Reads an optional text field the way the JSON above actually spells absence. +/// +/// A voucher with no party carries `"party": ""`, not a missing key, so +/// `as_str()` yields `Some("")` -- which claims the voucher *has* a party whose +/// name is blank. Two separate guards then fired on a book that simply had a +/// voucher naming nobody: a catalogue lookup reported the whole 864-master book +/// incomplete, and `presence_text_blank` refused the window outright. +/// +/// Only the **observed** side uses this. A blank on a caller's proposal is input +/// the schema already refuses, and translating that to "absent" here would +/// quietly accept what the schema rejects. +fn present_text(value: &Value) -> Option<&str> { + value.as_str().filter(|text| !text.trim().is_empty()) +} + fn parse_numbering(args: &Value) -> Result { let declared = args .get("numbering") diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index 3c989bab..0ae12e95 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -463,6 +463,42 @@ fn a_window_row_becomes_a_book_voucher_without_inventing_a_remote_id() { assert_eq!(voucher.party(), Some("Bridge Nested Debtor WR4")); } +/// A voucher that names nobody reaches this adapter as `"party": ""`, because +/// the window JSON spells an absent string that way rather than omitting the +/// key. Read literally that says the voucher *has* a party whose name is blank, +/// and two guards then fired on a book that merely had such a voucher: the +/// catalogue check reported all 864 masters incomplete, and the presence engine +/// refused the window with `presence_text_blank`. +/// +/// Tally does send an empty party element -- it is a real shape, not our +/// invention -- which is exactly why this has to mean "no party" rather than +/// being refused. +#[test] +fn a_voucher_naming_nobody_has_no_party_rather_than_a_blank_one() { + let row = json!({ + "guid": format!("{CAPTURED_GUID}-00000002"), + "date": "20260901", + "voucher_number": "JV-2", + "voucher_type": "Journal", + "party": "", + "cancelled": false, + "optional": false, + "amounts": [ + {"ledger": "WR2 Purchases", "amount": "-12.50"}, + {"ledger": "WR2 Sales", "amount": "12.50"}, + ], + }); + let window = book_window("20260901", "20260901", WindowRead::Complete, &[row]) + .expect("a voucher with no party must not fail the window"); + let voucher = &window.vouchers()[0]; + assert_eq!( + voucher.party(), + None, + "an empty party is an absent party, not a party named the empty string" + ); + assert_eq!(voucher.magnitude().as_str(), "12.5"); +} + #[test] fn party_names_are_marked_for_egress_and_accounting_selectors_are_not() { let entry = json!({ @@ -1053,7 +1089,15 @@ async fn replay_the_twenty_invoice_engagement() { request["voucher_type"] = json!(kind); } let read = server.call_tool("vouchers", request).await; - assert_eq!(read["isError"], false, "the window read failed"); + // Carry the refusal into the failure text. Asserting on `isError` alone + // reports that the read failed and withholds the one thing that says why, + // which cost a full diagnosis round the first time this fired. + assert_eq!( + read["isError"], + false, + "the window read failed: {}", + serde_json::to_string(&read).unwrap_or_default() + ); let rows = read["structuredContent"]["result"]["items"] .as_array() .expect("items") From d06596afaf378bff7467fd56108aeef803035153 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 15 Sep 2026 23:48:06 +0530 Subject: [PATCH 91/91] Reseal after merging master Kept this branch's pin superset: master's 212 plus the four presence files it does not carry. Pin sets compared as paths against both parents -- none dropped from either. Regenerated, verified, then staged, in that order. Co-Authored-By: Claude Opus 5 --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index e91d505f..60a4f488 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": "5cba801d6ae030e1ca8f83907db1dd429246688ce82b64370ca31ea03e24bd5c", + "compatibility_surface_sha256": "c86a3689f0981f6015ddb908999701cb795bd8650330fd47d0d15414e5262986", "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 58065be1..d59de4e6 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -3,11 +3,11 @@ "files": [ { "path": ".github/workflows/ci.yml", - "sha256": "e9221b186b18d6c8df148e4492ac6733df55e772c041f3246f1aeee0249273c2" + "sha256": "c986297046d65dc1d218583410e693c9d017e1d72e3891d667012c8474db2ed1" }, { "path": ".github/workflows/dependency-security.yml", - "sha256": "3c014174f82b5935c97ae5d228f1c2ee71e03f00c327d667df1ca4ff27cf75f4" + "sha256": "fd70b9a317677100d6b760a369495f9e3c6a083d7cdbf8f1916267d97eaa250f" }, { "path": "docs/adr/0004-tally-write-safety.md", @@ -147,7 +147,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", - "sha256": "4caadad956d18f20012c413c24f391569cf92ede237fc5e83d67ede1fa62dc83" + "sha256": "79514d91c530e4670182f191577dc61c91e12cb56cf392e385dbac7431b5e40f" }, { "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", @@ -191,7 +191,7 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/src/lib.rs", - "sha256": "69329ad964d8713a317c6e7d59f62e30a623e3a6658fb44cdca3f33ebb6826e5" + "sha256": "c4a3ee066c59adf6cc30a97024deb3e15440fbdac42e226cd81ad833d322c03f" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/native_outstandings/compute.rs", @@ -283,7 +283,7 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/tests/simulator_corpus.rs", - "sha256": "d34d1b4fa89b06d8c7cd60f47de6976392de11c87fce1ee4842e659f0f01df03" + "sha256": "61b4f1e086b69db92ccd93f35353269a36865bfba420deb4a985e930bc987a58" }, { "path": "src-tauri/crates/bridge-tally-protocol/tests/stream_text_decoder.rs", @@ -866,5 +866,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "5cba801d6ae030e1ca8f83907db1dd429246688ce82b64370ca31ea03e24bd5c" + "manifest_sha256": "c86a3689f0981f6015ddb908999701cb795bd8650330fd47d0d15414e5262986" } \ No newline at end of file