diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index b5948781..a3c10393 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -141,7 +141,7 @@ proposal in it, however complete that proposal's own evidence was. on: - **a `REMOTEID` column that was never read.** A window declares - `RemoteIdEvidence::Observed` or `NotRead`, because "no voucher carried one" + `ColumnEvidence::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 @@ -216,6 +216,12 @@ 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. +Ambiguous narration evidence is bounded separately: at most 64 raw marker +occurrences per voucher, 100,000 retained marker memberships, and 4 MiB of +marker-key bytes per window are admitted before the marker index is built. The +typed refusals preserve the same rule as the comparison bounds: no evidence is +silently truncated into an `Absent` or `Present` verdict. + ### 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 @@ -246,9 +252,9 @@ Per proposed voucher, exactly one of: | `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`, -`SameDatePartyAmount`, `SamePartyAmount`, `SameDateAmount`, `SameDateParty` — -ordered by rule and then by the book +each** — `SharedRemoteId`, `SharedNarrationMarker`, +`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.** @@ -297,7 +303,7 @@ leaves open: 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 + `ColumnEvidence::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 @@ -527,7 +533,7 @@ human-approved batch — this ADR does not move. 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**, - so the adapter declares `RemoteIdEvidence::NotRead` and the tool's schema + so the adapter declares `ColumnEvidence::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. diff --git a/docs/adr/0018-narration-marker-identity-basis.md b/docs/adr/0018-narration-marker-identity-basis.md new file mode 100644 index 00000000..1c30f679 --- /dev/null +++ b/docs/adr/0018-narration-marker-identity-basis.md @@ -0,0 +1,207 @@ +# 0018 — The narration marker as an identity basis for voucher presence + +- Status: Accepted +- Date: 2026-09-11 +- Amends: [0017 — Voucher presence authority](0017-voucher-presence-authority.md) +- Depends on: [0016 — Master binding authority](0016-master-binding-authority.md) + +## Context + +ADR 0017 gives presence two bases for `Present`: a Tally-assigned `REMOTEID` +read back on a previous visit, and a manual voucher number unique on both +sides. Both are real, and **neither reaches the case Bridge is uniquely placed +to answer**: *did I write this voucher myself?* + +- `REMOTEID` does not reach it twice over. §3.3a verified Tally **overwrites** + the attribute, so the client key Bridge sent is not what comes back; and the + qualified `vouchers` read profile does not `FETCH REMOTEID` at all, which is + why every proposal carrying one currently returns + `RemoteIdEvidenceUnavailable` rather than an answer. +- A manual voucher number is the *operator's* series. It says nothing about who + posted the voucher, and under `Automatic` numbering it does not exist. + +Meanwhile Bridge already writes an identity into a field Tally does not own. +Every import appends `[BRIDGE:]` to `NARRATION`; the qualified +`vouchers` profile already fetches `NARRATION`; and §9.8's batch-identity +observation records readback retaining that attribution across a round trip, +with the `REMOTEID` and the narration marker deriving from the same value. + +So the channel exists, is already written, is already read, and has live +evidence behind it — and presence does not use it. That is the gap this ADR +closes. + +### The hazard that shapes the whole contract + +The marker is **not** the caller's transaction label. It is +`SHA-256("bridge.mcp.import.v1", batch_id, txn_id)` truncated to a UUID, where +`batch_id` is a random v4 UUID persisted per batch. That derivation exists +precisely because a caller's transaction label is, in the import module's own +words, "commonly reused" — §9.8 records a second batch reusing an earlier +caller label, and the domain-separated digest is what stopped the two colliding. + +But the scheme field is `Option`, and `None` means an +older file whose marker is **the raw caller label**. Bridge never rewrites a +saved file, so books in the field can hold both kinds. A bare label like +`txn-001` is not unique across batches, and treating one as an identity would +match a proposal against an unrelated voucher from an unrelated import — a +false `Present`, which drops an invoice silently. That is the exact failure ADR +0017 exists to prevent, and it is reachable by *adding* this basis carelessly. + +## Decision + +### 1. The proposal's marker is derived, never accepted + +A caller does not hand presence a marker string. It supplies the `batch_id` and +`bridge_txn_id` of the import it is asking about, and the adapter derives the +marker with the **same `import_identity` function the writer uses**. One +derivation, one place, no second implementation to drift. + +This is not ceremony, it is the whole safety argument. A derived marker is a +digest over a random batch UUID, so it cannot equal a legacy caller label +except by deliberate contrivance. Accepting an arbitrary marker string would +let a caller pass `txn-001` and match a legacy voucher from an unrelated batch. +The input shape is what makes the hazard in the Context unreachable, rather +than a validation rule that has to be remembered. + +### 2. Extraction is the adapter's job; the crate compares opaque strings + +`[BRIDGE:]` is a **Bridge writer convention, not a Tally fact**, and +`bridge-tally-core` is the portable contract layer. So the adapter extracts the +marker from the observed narration and the crate receives an opaque string it +never parses. + +The second reason is cost. An opaque string is hashable, so the book index is +built once and every proposal is a lookup. Extraction inside the crate would +mean a substring scan of every narration for every proposal — the shape of +allocation this contract has already had to correct twice. + +### 3. One marker, and it must be identifying + +A book voucher yields an identity marker only when its narration carries +**exactly one** well-formed `[BRIDGE:…]` occurrence *and* that occurrence is a +UUID of the version the writer stamps. Everything else — two markers, a +malformed one, or a well-formed legacy caller label — yields **no identity**. + +Both halves fail closed, for different reasons. Two markers mean the voucher +claims two imports, which is the middle case ADR 0017 forbids resolving; the +import path already treats it as an error (`import_verification_tag_ambiguous`) +rather than taking the first. A non-UUID marker is a legacy-scheme write, and +§1 is exactly the reason it must not decide. + +The **version** is checked and not only the spelling, because a transaction +label may legally *be* UUID-shaped: `valid_txn_id` admits hex and hyphens, so a +legacy-scheme write could carry a canonical v4 and a spelling check alone would +have called it batch-derived. `import_identity` builds through +`Uuid::Builder::from_custom_bytes`, which stamps version 8 and the RFC 4122 +variant, so requiring those rejects the whole impersonable class rather than +the fraction of it that happens to look wrong. + +Such a voucher is still a Bridge write, and losing that fact silently would be +its own defect. So the window reports `unidentified_bridge_writes`: a count of +rows carrying a reserved marker that could not identify one. Its well-formed +occurrences remain **non-decisive** `SharedNarrationMarker` candidates. An +occurrence on a different voucher conflicts with a manual-number, `REMOTEID`, +or identifying-marker selection rather than being discarded; an occurrence on +the selected voucher decides nothing by itself. Neither case can promote the +ambiguous narration to identity. + +The core accepts at most 64 raw occurrences from one ambiguous narration before +cloning any value, then admits at most 100,000 retained marker memberships and +4 MiB of marker-key bytes across a window before building the marker index. +Excess is a typed refusal, never truncation, so the withheld evidence cannot be +silently lost to an allocation limit. + +### 4. Whether the column was read is a fact about the read + +`BookWindow` declares `ColumnEvidence::Observed` or `NotRead` for the narration +exactly as ADR 0017 §2 requires for `REMOTEID`, and for the same reason: "no +voucher carried a marker" and "the read never fetched narration" are different +facts and only the first is evidence. A proposal supplying an import identity +against a window that did not read narration is `MarkerEvidenceUnavailable` — +it cannot be `Absent`, and it cannot be `Present` on a weaker basis either, +because the evidence that could contradict that basis was skipped. + +This ADR generalises `RemoteIdEvidence` into `ColumnEvidence` rather than +adding a second enum of identical shape. The concept was never about +`REMOTEID`; it was always "this column was or was not read". + +### 5. The marker ranks with the other identities, and disagreement is reported + +`NarrationMarker` is a third basis under ADR 0017 §5, not a tiebreaker and not +a fallback. Every rule there applies to it unchanged: unique on both sides, +one book voucher satisfies at most one proposal across bases, a cancelled or +optional voucher never yields `Present`, and a `Present` reports its +differences. + +All identity lookups continue to be resolved **before any of them settles**, so +a marker selecting one voucher while a `REMOTEID` or a manual number selects +another is `IdentityConflict`. Ranking them would be the move ADR 0016 refuses +when an identifier contradicts an exact name, and adding a third signal makes +that more important rather than less: there are now three ways to disagree. + +A unique marker also conflicts with a supplied manual number that selects no +observed voucher. That is `IdentityConflict`, with every matching identity kept +in the candidate set. An omitted number provides no contradictory identity; +an automatic number is not an identity. Both remain distinct from a supplied, +absent manual number. + +### 6. What this basis does not reach, said plainly + +The marker reaches **only vouchers Bridge itself wrote**. It says nothing about +the hand-keyed voucher, which is the case that produced ADR 0017's ₹36.13 +finding and the case the capability was built for. This basis adds reach where +Bridge has written before; it does not reduce the other two bases' work, and it +must not be read as making the manual-number basis redundant. + +### 7. The evidence, and its scope + +§9.8's batch-identity and native-selector observations record the marker +surviving a round trip on a **Silver 7.1 Journal**. That is one product mode +and one voucher type. The marker's survival on other voucher types is +unqualified, and this ADR does not claim it. + +What makes shipping that honest is the **direction of the unqualified +failure**. If a marker does not survive, the voucher reads as marker-absent and +the proposal falls through to the other bases — at worst producing a duplicate, +which is visible in the book and correctable. It cannot produce a false +`Present`, because a marker that was not written cannot match one that was. +The unqualified case fails toward the noisy direction, which is the posture ADR +0017 §7 sets out. + +**Known limit.** An operator who edits a narration removes the marker, costing +a `Present` and yielding a duplicate. Same direction, same reasoning: visible +and correctable rather than silent. + +## Consequences + +- Presence answers "did Bridge write this?" for every voucher Bridge wrote + under the batch-derived scheme, with no change to a qualified read profile — + `NARRATION` is already fetched. +- The `voucher_presence` input grows `batch_id` and `bridge_txn_id` per + proposed voucher, both optional. A proposal that supplies neither behaves + exactly as it does under ADR 0017. +- `RemoteIdEvidence` is renamed to `ColumnEvidence`. One type, two columns. +- Books written by the legacy scheme get a count, not an identity. If those + turn out to be common in the field, the follow-up is a qualification of + legacy-label matching under a batch the caller names — deliberately not + attempted here, because it needs the caller to bound the batch and this ADR + does not have that input. + +## Alternatives rejected + +**Accept a marker string from the caller.** Simpler input, and it makes the +legacy-collision hazard a validation rule someone has to remember instead of a +shape that cannot express the bad case. Rejected on §1. + +**Match legacy caller labels too, for reach.** This is the one alternative that +would increase coverage on older books, and it produces exactly the silent +failure — a label matching across unrelated batches — that presence exists to +prevent. Reach is not worth a false `Present`. + +**Extract the marker inside the crate.** Puts a Bridge writer convention in the +portable layer and turns a hash lookup into a per-proposal substring scan. + +**Treat the first of several markers as the identity.** Cheap, matches what one +existing readback helper does, and silently resolves the middle case. The +import path's stricter helper refuses it, and this contract agrees with the +stricter one. diff --git a/docs/module-decomposition.md b/docs/module-decomposition.md index 7336633a..07dc5378 100644 --- a/docs/module-decomposition.md +++ b/docs/module-decomposition.md @@ -60,14 +60,14 @@ manifest cannot tell you which, because it records paths and not reasons — and that is the actual problem: the seal's boundary is currently an accident of history rather than a decision anyone can review. -That needs capacity in `MAX_SURFACE_FILES`, and there is none by design — 217 of -217. +That needs capacity in `MAX_SURFACE_FILES`, and there is none by design — 218 of +218. **Do not read that as a shortage to be fixed.** `RESERVED_SURFACE_FILES` is documented as capacity for *"one small cohesive surface change"*, and the cap's own rationale says it *"makes further unreviewed additions an explicit compatibility-surface decision"*, closing with *"one file for one named reason — -not headroom."* The cap has been raised four times, each reason recorded in the +not headroom."* The cap has been raised five times, each reason recorded in the comment, and three of those raises came from branches that could not see each other. The friction is the control. diff --git a/docs/proposed-rust-module-conventions.md b/docs/proposed-rust-module-conventions.md index 7f6903ca..5e72dfcc 100644 --- a/docs/proposed-rust-module-conventions.md +++ b/docs/proposed-rust-module-conventions.md @@ -89,7 +89,7 @@ argues for doing it where it helps and against treating it as risky. ## Where the general advice does not fit this repository This codebase has a constraint most advice does not consider: **files are -pinned**. `docs/tally/compatibility/compatibility-surface.json` binds 217 paths +pinned**. `docs/tally/compatibility/compatibility-surface.json` binds 218 paths by SHA-256, and the evidence receipts beneath it attest behaviour *of those bytes*. Splitting a pinned file is not a neutral move: diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 2fe0bad3..c2376112 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": "f26f33a831ad741e2918ae32c5db9af16cfec713f102e30df6a30b9f886ac6cd", + "compatibility_surface_sha256": "632ae96b7599ff062311687f9188c8001ec53b655c07cf7986be878f419e7781", "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 10d31366..b854d009 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/book_presence.rs", - "sha256": "652f57e441b05f4bdb25c8e53e9bcd9000641491e2fb8479c0a6344764b2b54c" + "sha256": "46061242d2502a140905c7acbb235d56e3912dd9362930e64c32cfa37083ef53" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -335,7 +335,7 @@ }, { "path": "src-tauri/src/agent_catalog.rs", - "sha256": "e36e4cbe60e6355226b8779e82395ec79309213f8c0dca59aea8bbc745252601" + "sha256": "55632c680704c2784545e422cf6847b81d2423191d651a0119d45188d9bf2671" }, { "path": "src-tauri/src/agent_desktop_journal.rs", @@ -343,7 +343,11 @@ }, { "path": "src-tauri/src/agent_import.rs", - "sha256": "47c1dc77fa33d7e4bcece1cb289e4a3535881391fe45f392e2dca0daa937930f" + "sha256": "0259e8828d9c6bea10dd98f523e7c982137eed5354097efd5b866475d703f74e" + }, + { + "path": "src-tauri/src/agent_import_identity.rs", + "sha256": "124fbf3d2d255523ce9fc9dbf32502eb3c8dd013c9dd2a7b206eafd966611d89" }, { "path": "src-tauri/src/agent_ledgers.rs", @@ -351,11 +355,11 @@ }, { "path": "src-tauri/src/agent_presence.rs", - "sha256": "44fa9f2482f1c1a76249bccdfa0b5e2626d816e9a405b721e04c8ef7da9d8ec5" + "sha256": "0bfeb05248b0d371e6a87b4b004c9c3815b5590017c6bd3231277db12b030c8d" }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "f2479d47fa01119388cb7e007b5b554fa47a57292251f4489224bf130bfd272c" + "sha256": "11d35d8a5dd60178f02d070c8b33f9b2c1cf44cb7cf5517eac11430f2aa74796" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -835,7 +839,7 @@ }, { "path": "tools/bridge-tally-compatibility/src/lib.rs", - "sha256": "e61a4815ce3d887fdb46481917546002f7d90d0ec913752aebaa68d232aa79e1" + "sha256": "0ea7fb49d64fddab95f148048c92c07d9f9cd162a973912e3a56df2051ee3b99" }, { "path": "tools/bridge-tally-compatibility/src/main.rs", @@ -870,5 +874,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "f26f33a831ad741e2918ae32c5db9af16cfec713f102e30df6a30b9f886ac6cd" + "manifest_sha256": "632ae96b7599ff062311687f9188c8001ec53b655c07cf7986be878f419e7781" } \ 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 85817224..f7f41613 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -1,12 +1,14 @@ //! 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 +//! See `docs/adr/0017-voucher-presence-authority.md`, amended by +//! `docs/adr/0018-narration-marker-identity-basis.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`. +//! Four rules carry the contract. Only an identity key — a `REMOTEID`, the +//! narration marker Bridge wrote on a previous import, 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 @@ -32,9 +34,7 @@ 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. +/// Maximum proposal-to-window resemblance comparisons per request. 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. @@ -56,16 +56,18 @@ 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 -/// 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. +/// The party-resemblance index retains every membership once more. The +/// per-voucher limits otherwise admit 40 million memberships in that index. 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 well-formed occurrences retained from one ambiguous narration. +/// The raw input count is checked before any marker is cloned into its set. +pub const MAX_AMBIGUOUS_MARKERS_PER_VOUCHER: usize = 64; +/// Most retained ambiguous-marker memberships across one window. +pub const MAX_WINDOW_AMBIGUOUS_MARKER_MEMBERSHIPS: usize = 100_000; +/// Most UTF-8 bytes in retained ambiguous-marker keys across one window. +pub const MAX_WINDOW_AMBIGUOUS_MARKER_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. @@ -117,6 +119,10 @@ pub enum PresenceError { WindowRawEntryBytesTooLarge, #[error("book window ledger keys exceeded their aggregate byte bound")] WindowLedgerKeyBytesTooLarge, + #[error("book window ambiguous marker memberships exceeded their aggregate bound")] + WindowAmbiguousMarkerMembershipsTooMany, + #[error("book window ambiguous marker keys exceeded their aggregate byte bound")] + WindowAmbiguousMarkerKeyBytesTooLarge, #[error("book window carried a voucher dated outside its own range")] WindowVoucherOutsideRange, #[error("book window carried the same voucher key twice")] @@ -126,6 +132,9 @@ 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, + /// The same contradiction, for the narration column. + #[error("book window declared NARRATION unread while carrying a marker")] + WindowNarrationContradiction, #[error("book voucher key exceeded its bound")] VoucherKeyTooLong, /// A proposal dated outside the window would be judged against evidence @@ -148,6 +157,8 @@ pub enum PresenceError { EntriesEmpty, #[error("voucher entry list exceeded its bound")] TooManyEntries, + #[error("ambiguous narration marker occurrences exceeded their bound")] + TooManyAmbiguousMarkers, /// 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")] @@ -187,9 +198,16 @@ impl PresenceError { 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::WindowAmbiguousMarkerMembershipsTooMany => { + "presence_window_ambiguous_marker_memberships_too_many" + } + Self::WindowAmbiguousMarkerKeyBytesTooLarge => { + "presence_window_ambiguous_marker_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", + Self::WindowNarrationContradiction => "presence_window_narration_contradiction", Self::VoucherKeyTooLong => "presence_voucher_key_too_long", Self::WindowDoesNotCover => "presence_window_does_not_cover", Self::ProposalsEmpty => "presence_proposals_empty", @@ -200,6 +218,7 @@ impl PresenceError { Self::ComparisonWorkTooLarge => "presence_comparison_work_too_large", Self::EntriesEmpty => "presence_entries_empty", Self::TooManyEntries => "presence_entries_too_many", + Self::TooManyAmbiguousMarkers => "presence_ambiguous_markers_too_many", Self::NumberingMethodUndeclared => "presence_numbering_method_undeclared", Self::NumberingMethodConflict => "presence_numbering_method_conflict", Self::NumberingDeclarationsTooMany => "presence_numbering_declarations_too_many", @@ -224,7 +243,7 @@ impl PresenceError { /// 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 { +pub enum ColumnEvidence { /// The read fetched `REMOTEID`; an absent value means the voucher has none. Observed, /// The read did not fetch `REMOTEID`; absence means nothing at all. @@ -279,6 +298,34 @@ pub struct ObservedEntry<'a> { pub amount: &'a str, } +/// What a read found in one voucher's narration, once the adapter has applied +/// the `[BRIDGE:...]` convention to it. +/// +/// The convention is a *Bridge writer* convention, not a Tally fact, so it is +/// applied above this crate and the identity arrives here as an opaque string +/// this crate never parses. That also keeps the book index a hash lookup +/// instead of a substring scan of every narration per proposal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObservedMarker<'a> { + /// The narration carried no reserved marker. + Absent, + /// Exactly one marker, and it identifies a single import. + Identifying(&'a str), + /// A reserved marker was present and could not identify one import -- + /// two of them, a malformed one, or one written under a scheme whose + /// values are not unique across batches. The voucher is a Bridge write + /// this window cannot name: a finding for a person, never an identity. + /// + /// The well-formed occurrences travel with it. They may not **decide** -- + /// a voucher claiming two imports is the middle case this contract never + /// resolves -- but discarding them loses real evidence: a proposal whose + /// own marker is among them is asking about this very voucher, and saying + /// `Absent` to it invites the duplicate the whole contract exists to + /// prevent. They surface as candidates instead, which decides nothing and + /// withholds the absence. + Unidentified(&'a [&'a str]), +} + /// One voucher as the book was observed to hold it. #[derive(Debug, Clone, Copy)] pub struct ObservedVoucher<'a> { @@ -292,6 +339,9 @@ pub struct ObservedVoucher<'a> { pub remote_id: Option<&'a str>, /// `PARTYLEDGERNAME`, when the read carried one. pub party: Option<&'a str>, + /// The `[BRIDGE:...]` identity this voucher's narration carried, as the + /// adapter read it. + pub marker: ObservedMarker<'a>, pub entries: &'a [ObservedEntry<'a>], pub cancelled: bool, pub optional: bool, @@ -304,6 +354,10 @@ pub struct ProposedVoucherInput<'a> { pub date: &'a str, pub voucher_type: &'a str, pub voucher_number: Option<&'a str>, + /// The `[BRIDGE:...]` identity a previous import would have written for + /// this voucher, derived by the adapter from the batch it is asking about. + /// Never a value the caller chose: see ADR 0018 §1. + pub narration_marker: 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. @@ -330,6 +384,7 @@ impl RawProposalBudget { input.date, input.voucher_type, input.voucher_number, + input.narration_marker, input.remote_id, input.party, input @@ -346,6 +401,7 @@ impl RawProposalBudget { date: &'a str, voucher_type: &'a str, voucher_number: Option<&'a str>, + narration_marker: Option<&'a str>, remote_id: Option<&'a str>, party: Option<&'a str>, entries: impl IntoIterator, @@ -361,6 +417,7 @@ impl RawProposalBudget { date, voucher_type, voucher_number.unwrap_or_default(), + narration_marker.unwrap_or_default(), remote_id.unwrap_or_default(), party.unwrap_or_default(), ]; @@ -405,6 +462,15 @@ pub struct BookVoucher { remote_id: Option, party: Option, observed_ledgers: BTreeSet, + /// Set only for `ObservedMarker::Identifying`, so an unidentifiable marker + /// cannot reach a lookup by being stored beside an identifying one. + marker: Option, + /// Set only for `ObservedMarker::Unidentified`. Counted for a person, and + /// the markers it carried are reachable as candidates but never as an + /// identity. Both are private and derived from one input, so no caller can + /// construct a voucher that is somehow both. + unidentified_bridge_write: bool, + ambiguous_markers: BTreeSet, ledger_keys: BTreeSet, magnitude: ExactDecimal, balanced: bool, @@ -425,15 +491,30 @@ 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 marker = match input.marker { + ObservedMarker::Identifying(marker) => Some(validated_text(marker)?), + ObservedMarker::Absent | ObservedMarker::Unidentified(_) => None, + }; + let ambiguous_markers = match input.marker { + ObservedMarker::Unidentified(markers) => { + if markers.len() > MAX_AMBIGUOUS_MARKERS_PER_VOUCHER { + return Err(PresenceError::TooManyAmbiguousMarkers); + } + markers + .iter() + .map(|marker| validated_text(marker)) + .collect::, _>>()? + } + _ => BTreeSet::new(), + }; 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, - // no source observation qualifies case, whitespace, or separator - // folding for them, so preserve their validated spelling exactly. + // Voucher types are part of an identity key. No source observation + // qualifies the ledger-name fold for them, so keep their spelling. let type_key = voucher_type.clone(); let number_key = voucher_number.as_deref().map(number_key_of); Ok(Self { @@ -444,6 +525,9 @@ impl BookVoucher { remote_id, party, observed_ledgers, + marker, + unidentified_bridge_write: matches!(input.marker, ObservedMarker::Unidentified(_)), + ambiguous_markers, ledger_keys, magnitude, balanced, @@ -489,6 +573,7 @@ pub struct ProposedVoucher { voucher_type: String, voucher_number: Option, remote_id: Option, + narration_marker: Option, party: Option, magnitude: ExactDecimal, type_key: String, @@ -519,6 +604,7 @@ impl ProposedVoucher { voucher_type, voucher_number, remote_id, + narration_marker: input.narration_marker.map(validated_text).transpose()?, party, magnitude, type_key, @@ -596,10 +682,26 @@ pub struct BookWindow { from: TallyDate, to: TallyDate, read: WindowRead, - remote_id_evidence: RemoteIdEvidence, + remote_id_evidence: ColumnEvidence, + narration_evidence: ColumnEvidence, vouchers: Vec, } +/// What a caller states about the read that produced a window. A struct rather +/// than five positional arguments, because two of them are the same type and +/// transposing them would silently invert an evidence claim. +#[derive(Debug)] +pub struct ObservedWindow<'a, V = Vec> { + pub from: &'a str, + pub to: &'a str, + pub read: WindowRead, + /// Whether the read fetched `REMOTEID`. + pub remote_id_evidence: ColumnEvidence, + /// Whether the read fetched `NARRATION`. + pub narration_evidence: ColumnEvidence, + pub vouchers: V, +} + /// Incremental admission for raw voucher rows. Both adapters and the core /// window boundary use this before retaining entry descriptors. #[derive(Debug, Default)] @@ -608,6 +710,7 @@ pub struct RawObservationBudget { entries: usize, bytes: usize, metadata_bytes: usize, + ambiguous_marker_memberships: usize, } impl RawObservationBudget { @@ -620,8 +723,30 @@ impl RawObservationBudget { voucher_number: Option<&'a str>, remote_id: Option<&'a str>, party: Option<&'a str>, + identifying_marker: Option<&'a str>, + ambiguous_markers: impl IntoIterator, entries: impl IntoIterator, ) -> Result<(), PresenceError> { + let mut marker_bytes = identifying_marker.map(str::len).unwrap_or_default(); + let mut ambiguous_count = 0usize; + for marker in ambiguous_markers { + ambiguous_count = ambiguous_count + .checked_add(1) + .ok_or(PresenceError::TooManyAmbiguousMarkers)?; + if ambiguous_count > MAX_AMBIGUOUS_MARKERS_PER_VOUCHER { + return Err(PresenceError::TooManyAmbiguousMarkers); + } + marker_bytes = marker_bytes + .checked_add(marker.len()) + .ok_or(PresenceError::WindowRawEntryBytesTooLarge)?; + } + self.ambiguous_marker_memberships = self + .ambiguous_marker_memberships + .checked_add(ambiguous_count) + .ok_or(PresenceError::WindowAmbiguousMarkerMembershipsTooMany)?; + if self.ambiguous_marker_memberships > MAX_WINDOW_AMBIGUOUS_MARKER_MEMBERSHIPS { + return Err(PresenceError::WindowAmbiguousMarkerMembershipsTooMany); + } let metadata = [ key, date, @@ -633,6 +758,7 @@ impl RawObservationBudget { let metadata_bytes = metadata .iter() .try_fold(0usize, |total, value| total.checked_add(value.len())) + .and_then(|total| total.checked_add(marker_bytes)) .ok_or(PresenceError::WindowRawEntryBytesTooLarge)?; self.metadata_bytes = self .metadata_bytes @@ -648,6 +774,12 @@ impl RawObservationBudget { &mut self, observation: &ObservedVoucher<'_>, ) -> Result<(), PresenceError> { + let (identifying_marker, ambiguous_markers): (Option<&str>, &[&str]) = + match observation.marker { + ObservedMarker::Absent => (None, &[]), + ObservedMarker::Identifying(marker) => (Some(marker), &[]), + ObservedMarker::Unidentified(markers) => (None, markers), + }; self.admit_fields( observation.key, observation.date, @@ -655,6 +787,8 @@ impl RawObservationBudget { observation.voucher_number, observation.remote_id, observation.party, + identifying_marker, + ambiguous_markers.iter().copied(), observation .entries .iter() @@ -695,31 +829,35 @@ impl RawObservationBudget { } impl BookWindow { - /// Admits raw observations in aggregate before the per-voucher conversion - /// performs decimal parsing, string cloning, and comparison-key folding. + /// Admit raw rows before per-voucher parsing, cloning or folding. pub fn from_observations<'a>( - from: &str, - to: &str, - read: WindowRead, - remote_id_evidence: RemoteIdEvidence, - observations: impl IntoIterator>, + input: ObservedWindow<'_, impl IntoIterator>>, ) -> Result { let mut budget = RawObservationBudget::default(); let mut vouchers = Vec::new(); - for observation in observations { + for observation in input.vouchers { budget.admit_observation(&observation)?; vouchers.push(BookVoucher::observed(observation)?); } - Self::observed(from, to, read, remote_id_evidence, vouchers) + Self::observed(ObservedWindow { + from: input.from, + to: input.to, + read: input.read, + remote_id_evidence: input.remote_id_evidence, + narration_evidence: input.narration_evidence, + vouchers, + }) } - pub(crate) fn observed( - from: &str, - to: &str, - read: WindowRead, - remote_id_evidence: RemoteIdEvidence, - vouchers: Vec, - ) -> Result { + pub(crate) fn observed(input: ObservedWindow<'_>) -> Result { + let ObservedWindow { + from, + to, + read, + remote_id_evidence, + narration_evidence, + vouchers, + } = input; 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() { @@ -731,6 +869,8 @@ impl BookWindow { let mut keys = BTreeSet::new(); let mut ledger_memberships = 0usize; let mut ledger_key_bytes = 0usize; + let mut ambiguous_marker_memberships = 0usize; + let mut ambiguous_marker_key_bytes = 0usize; for voucher in &vouchers { if voucher.date() < from.as_str() || voucher.date() > to.as_str() { return Err(PresenceError::WindowVoucherOutsideRange); @@ -738,9 +878,16 @@ impl BookWindow { if !keys.insert(voucher.key()) { return Err(PresenceError::WindowDuplicateVoucherKey); } - if remote_id_evidence == RemoteIdEvidence::NotRead && voucher.remote_id.is_some() { + if remote_id_evidence == ColumnEvidence::NotRead && voucher.remote_id.is_some() { return Err(PresenceError::WindowRemoteIdContradiction); } + // Same rule, other column: a window claiming narration was never + // read cannot also carry something read out of a narration. + if narration_evidence == ColumnEvidence::NotRead + && (voucher.marker.is_some() || voucher.unidentified_bridge_write) + { + return Err(PresenceError::WindowNarrationContradiction); + } let retained_memberships = voucher .ledger_keys .len() @@ -764,12 +911,30 @@ impl BookWindow { if ledger_key_bytes > MAX_WINDOW_LEDGER_KEY_BYTES { return Err(PresenceError::WindowLedgerKeyBytesTooLarge); } + ambiguous_marker_memberships = ambiguous_marker_memberships + .checked_add(voucher.ambiguous_markers.len()) + .ok_or(PresenceError::WindowAmbiguousMarkerMembershipsTooMany)?; + if ambiguous_marker_memberships > MAX_WINDOW_AMBIGUOUS_MARKER_MEMBERSHIPS { + return Err(PresenceError::WindowAmbiguousMarkerMembershipsTooMany); + } + let voucher_marker_key_bytes = voucher + .ambiguous_markers + .iter() + .try_fold(0usize, |total, marker| total.checked_add(marker.len())) + .ok_or(PresenceError::WindowAmbiguousMarkerKeyBytesTooLarge)?; + ambiguous_marker_key_bytes = ambiguous_marker_key_bytes + .checked_add(voucher_marker_key_bytes) + .ok_or(PresenceError::WindowAmbiguousMarkerKeyBytesTooLarge)?; + if ambiguous_marker_key_bytes > MAX_WINDOW_AMBIGUOUS_MARKER_KEY_BYTES { + return Err(PresenceError::WindowAmbiguousMarkerKeyBytesTooLarge); + } } Ok(Self { from, to, read, remote_id_evidence, + narration_evidence, vouchers, }) } @@ -792,10 +957,14 @@ impl BookWindow { self.read } - pub fn remote_id_evidence(&self) -> RemoteIdEvidence { + pub fn remote_id_evidence(&self) -> ColumnEvidence { self.remote_id_evidence } + pub fn narration_evidence(&self) -> ColumnEvidence { + self.narration_evidence + } + fn covers(&self, date: &str) -> bool { date >= self.from.as_str() && date <= self.to.as_str() } @@ -880,15 +1049,31 @@ pub enum PartyOutcome { #[serde(rename_all = "snake_case")] pub enum PresenceBasis { RemoteId, + /// The identity Bridge itself wrote into `NARRATION` on a previous import, + /// read back and matched. Reaches only vouchers Bridge wrote (ADR 0018). + NarrationMarker, ManualVoucherNumber, } +impl PresenceBasis { + /// The candidate rule that corresponds to this basis. Stated once: a + /// second site pairing them by hand is how the two come to disagree. + fn candidate_rule(self) -> CandidateRule { + match self { + Self::RemoteId => CandidateRule::SharedRemoteId, + Self::NarrationMarker => CandidateRule::SharedNarrationMarker, + Self::ManualVoucherNumber => CandidateRule::SharedVoucherNumber, + } + } +} + /// 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, + SharedNarrationMarker, SharedVoucherNumber, SameDatePartyAmount, SamePartyAmount, @@ -900,11 +1085,12 @@ 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, + Self::SharedNarrationMarker => 1, + Self::SharedVoucherNumber => 2, + Self::SameDatePartyAmount => 3, + Self::SamePartyAmount => 4, + Self::SameDateAmount => 5, + Self::SameDateParty => 6, } } } @@ -975,6 +1161,16 @@ pub enum UndecidedReason { /// declared range: what looks like "no match found" may only be "no /// match found in the part that was read". WindowNotProvenComplete, + /// One narration marker is carried by more than one voucher on either + /// side. Bridge writes a distinct identity per imported voucher, so this + /// is a book anomaly rather than an ordinary ambiguity -- and it is still + /// never resolved by picking one. + NarrationMarkerCollision, + /// The proposal supplies an import identity the window never read the + /// narration for. Same rule as `RemoteIdEvidenceUnavailable`, other + /// column: the key was skipped, so neither `Absent` nor a `Present` on a + /// weaker basis is available. + MarkerEvidenceUnavailable, } impl UndecidedReason { @@ -995,6 +1191,8 @@ impl UndecidedReason { Self::IdentityConflict => "presence_identity_conflict", Self::RemoteIdEvidenceUnavailable => "presence_remote_id_evidence_unavailable", Self::WindowNotProvenComplete => "presence_window_not_proven_complete", + Self::NarrationMarkerCollision => "presence_narration_marker_collision", + Self::MarkerEvidenceUnavailable => "presence_marker_evidence_unavailable", } } } @@ -1126,6 +1324,19 @@ pub struct BookObservations { /// 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, + /// Whether any observed voucher carried an identifying narration marker. + /// Same reason as `remote_id_observed`: on a book Bridge has never written + /// to, no marker matching anything is expected rather than informative. + pub narration_marker_observed: bool, + /// Vouchers carrying a reserved `[BRIDGE:...]` marker that could not + /// identify one import — two markers, a malformed one, or a legacy scheme + /// whose values are not unique across batches (ADR 0018 §3). + /// + /// Counted rather than matched. These are Bridge writes this window cannot + /// name, and losing that silently would be its own defect; but one that + /// also resembles a proposal already surfaces as a candidate under the + /// ordinary resemblance rules, so the count needs no rule of its own. + pub unidentified_bridge_writes: usize, } /// Control totals for one run. `requested == present + possibly_present + @@ -1266,11 +1477,11 @@ impl<'a> PresenceRequest<'a> { { return Err(PresenceError::DuplicateProposalPosition); } - let comparisons = proposals + let comparison_work = proposals .len() .checked_mul(window.vouchers().len()) .ok_or(PresenceError::ComparisonWorkTooLarge)?; - if comparisons > MAX_PRESENCE_COMPARISONS { + if comparison_work > MAX_PRESENCE_COMPARISONS { return Err(PresenceError::ComparisonWorkTooLarge); } for proposal in proposals { @@ -1400,6 +1611,10 @@ fn resolution_of(binding: &master_binding::EntityBinding) -> PartyResolution { /// Indexes of one window, built once per run. struct WindowIndex<'a> { by_remote_id: BTreeMap<&'a str, Vec>, + by_marker: BTreeMap<&'a str, Vec>, + /// Vouchers whose narration carried a marker that could not identify one + /// import. Never consulted for identity; only to surface a candidate. + by_ambiguous_marker: 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>, @@ -1411,6 +1626,8 @@ impl<'a> WindowIndex<'a> { fn build(window: &'a BookWindow) -> Self { let mut index = Self { by_remote_id: BTreeMap::new(), + by_marker: BTreeMap::new(), + by_ambiguous_marker: BTreeMap::new(), by_type_and_number: BTreeMap::new(), by_number: BTreeMap::new(), by_date: BTreeMap::new(), @@ -1426,6 +1643,16 @@ impl<'a> WindowIndex<'a> { .or_default() .push(position); } + if let Some(marker) = voucher.marker.as_deref() { + index.by_marker.entry(marker).or_default().push(position); + } + for marker in &voucher.ambiguous_markers { + index + .by_ambiguous_marker + .entry(marker.as_str()) + .or_default() + .push(position); + } if let Some(number_key) = voucher.number_key.as_deref() { index .by_type_and_number @@ -1502,11 +1729,15 @@ pub fn assess(request: &PresenceRequest<'_>) -> PresenceReport { let index = WindowIndex::build(window); let mut proposal_remote_counts: BTreeMap<&str, usize> = BTreeMap::new(); + let mut proposal_marker_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(marker) = proposal.narration_marker.as_deref() { + *proposal_marker_counts.entry(marker).or_default() += 1; + } if let Some(number_key) = proposal.number_key.as_deref() { *proposal_number_counts .entry((proposal.type_key.as_str(), number_key)) @@ -1526,6 +1757,7 @@ pub fn assess(request: &PresenceRequest<'_>) -> PresenceReport { &index, request.numbering, &proposal_remote_counts, + &proposal_marker_counts, &proposal_number_counts, ); touched_book.extend(decided.touched); @@ -1559,11 +1791,13 @@ pub fn assess(request: &PresenceRequest<'_>) -> PresenceReport { continue; } let book_key = book_key.to_string(); + // The basis that found it is the evidence a reviewer needs, so it + // goes through the one mapping rather than a second hand-written + // one. The wildcard here used to bucket every non-`REMOTEID` basis + // as a shared number, which reported a marker match as a number + // match — a demotion is not a licence to misdescribe what matched. let rule = match &entry.status { - PresenceStatus::Present { - basis: PresenceBasis::RemoteId, - .. - } => CandidateRule::SharedRemoteId, + PresenceStatus::Present { basis, .. } => basis.candidate_rule(), _ => CandidateRule::SharedVoucherNumber, }; entry.status = PresenceStatus::PossiblyPresent(undecided( @@ -1599,6 +1833,7 @@ fn decide( index: &WindowIndex<'_>, numbering: &NumberingDeclaration, proposal_remote_counts: &BTreeMap<&str, usize>, + proposal_marker_counts: &BTreeMap<&str, usize>, proposal_number_counts: &BTreeMap<(&str, &str), usize>, ) -> Decided { let method = numbering @@ -1616,10 +1851,20 @@ fn decide( }, 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; + // A proposal carrying a key the window never fetched has had that key + // silently skipped. That cannot license an absence, and it cannot license + // a `Present` on some *other* basis either: the evidence that could have + // contradicted the other basis is the evidence that was not gathered. + let skipped_evidence = + if proposal.remote_id.is_some() && window.remote_id_evidence() == ColumnEvidence::NotRead { + Some(UndecidedReason::RemoteIdEvidenceUnavailable) + } else if proposal.narration_marker.is_some() + && window.narration_evidence() == ColumnEvidence::NotRead + { + Some(UndecidedReason::MarkerEvidenceUnavailable) + } else { + None + }; // Both identity lookups are resolved *before* either settles, so that a // `REMOTEID` selecting one voucher while the number selects another can be @@ -1653,82 +1898,93 @@ fn decide( 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); - 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), - )), - with_resemblances(matches.iter().copied().collect()), + // Rule one: identity first, and *every* identity is resolved before any of + // them settles. A `REMOTEID` and a narration marker are both keys Bridge + // itself wrote; only their survival differs (§3.3a, §9.8). Resolving them + // together is what lets a disagreement be reported instead of decided by + // whichever branch happened to run first. + let lookup = |key: Option<&str>, table: &BTreeMap<&str, Vec>| -> Vec { + key.and_then(|key| table.get(key)) + .cloned() + .unwrap_or_default() + }; + let remote_id_matches = lookup(proposal.remote_id.as_deref(), &index.by_remote_id); + let marker_matches = lookup(proposal.narration_marker.as_deref(), &index.by_marker); + // A voucher counted here is still carrying this marker even though it + // could not identify anything on its own (`by_ambiguous_marker`). Checking + // uniqueness against `marker_matches` alone let one such voucher hide + // behind an unrelated identifying one: the identifying voucher looked + // unique, and `Present` went out for it while the marker actually named + // two book vouchers -- exactly the middle case ambiguous-marker handling + // exists to preserve, undone by counting only half of it. + let ambiguous_marker_matches = lookup( + proposal.narration_marker.as_deref(), + &index.by_ambiguous_marker, + ); + let marker_matches_with_ambiguous: Vec = marker_matches + .iter() + .copied() + .chain(ambiguous_marker_matches.iter().copied()) + .collect(); + + // A collision on one identity cannot erase an already observed match on + // another. Retain every resolved identity basis in the operator-facing + // candidates and the full touched set before returning the collision. + // Otherwise a duplicated source REMOTEID could hide the distinct narration + // markers that identify each source row, and the book rows would be + // misreported as unmatched. + let mut collision_identities = BTreeMap::new(); + for (positions, rule) in [ + (&remote_id_matches, CandidateRule::SharedRemoteId), + ( + &marker_matches_with_ambiguous, + CandidateRule::SharedNarrationMarker, + ), + ] { + for position in positions { + keep_strongest(&mut collision_identities, *position, rule); + } + } + if method == NumberingMethod::Manual { + for position in &number_matches { + keep_strongest( + &mut collision_identities, + *position, + CandidateRule::SharedVoucherNumber, ); } - if !matches.is_empty() { - // 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 - // of them is the move this contract refuses everywhere else. - let number_selects_another = method == NumberingMethod::Manual - && proposal_number_counts - .get(&( - proposal.type_key.as_str(), - proposal.number_key.as_deref().unwrap_or_default(), - )) - .copied() - == Some(1) - && (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.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 - // 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_ranked(window, &mut entries), - )), - with_resemblances(touched), - ); - } - return shell( - settled( - proposal, - party, - &window.vouchers[matches[0]], - PresenceBasis::RemoteId, - ), - with_resemblances(BTreeSet::from([matches[0]])), - ); - } + } + let collision_touched = collision_identities + .keys() + .copied() + .collect::>(); + let mut collision_entries = collision_identities.into_iter().collect::>(); + let collision_candidates = candidates_ranked(window, &mut collision_entries); + + // Uniqueness is required on *both* sides, and the proposal side is checked + // first: 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. One loop, because a second basis copying this block by hand is + // how the two come to disagree about what a collision is. + for (key, counts, matches, reason) in [ + ( + proposal.remote_id.as_deref(), + proposal_remote_counts, + &remote_id_matches, + UndecidedReason::RemoteIdCollision, + ), + ( + proposal.narration_marker.as_deref(), + proposal_marker_counts, + &marker_matches_with_ambiguous, + UndecidedReason::NarrationMarkerCollision, + ), + ] { + let Some(key) = key else { continue }; + if counts.get(key).copied() != Some(1) || matches.len() > 1 { return shell( - PresenceStatus::PossiblyPresent(undecided( - UndecidedReason::RemoteIdCollision, - candidates_from(window, matches, CandidateRule::SharedRemoteId), - )), - with_resemblances(matches.iter().copied().collect()), + PresenceStatus::PossiblyPresent(undecided(reason, collision_candidates.clone())), + with_resemblances(collision_touched.clone()), ); } } @@ -1736,10 +1992,16 @@ 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. // + // These ambiguities are only consulted when no stronger identity resolved. + // A number shared by two source rows does not make an unambiguous + // `REMOTEID` or marker less decisive, and reporting the ambiguity instead + // would answer a question the proposal had already answered. + let stronger_selected = remote_id_matches.len() == 1 || marker_matches.len() == 1; + // 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 !stronger_selected && 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)) @@ -1750,13 +2012,9 @@ fn decide( return shell( PresenceStatus::PossiblyPresent(undecided( UndecidedReason::ProposalNumberCollision, - candidates_from( - window, - &number_matches, - CandidateRule::SharedVoucherNumber, - ), + collision_candidates.clone(), )), - with_resemblances(number_matches.iter().copied().collect()), + with_resemblances(collision_touched.clone()), ); } } @@ -1764,62 +2022,126 @@ 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 && !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 { + let number_decides = + method == NumberingMethod::Manual && type_observed && !number_matches.is_empty(); + if !stronger_selected && number_decides && number_matches.len() > 1 { + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::BookNumberCollision, + collision_candidates.clone(), + )), + with_resemblances(collision_touched.clone()), + ); + } + + // A strong identity that uniquely selects a book voucher conflicts with a + // supplied manual number absent from that voucher series. Treat the two + // identities as contradictory rather than allowing the stronger key to + // settle a row whose number evidence disagrees. + if stronger_selected + && method == NumberingMethod::Manual + && proposal.number_key.is_some() + && number_matches.is_empty() + { + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::IdentityConflict, + collision_candidates.clone(), + )), + with_resemblances(collision_touched.clone()), + ); + } + + // Everything that resolved to exactly one book voucher, strongest basis + // first. Nothing has settled yet, which is the whole point: a disagreement + // between any two of them is still reportable. + let mut selections: Vec<(PresenceBasis, usize)> = Vec::new(); + if let [position] = remote_id_matches[..] { + selections.push((PresenceBasis::RemoteId, position)); + } + if let [position] = marker_matches[..] { + selections.push((PresenceBasis::NarrationMarker, position)); + } + // A proposal-side duplicate makes the number unusable as an identity + // basis. It must stay out of `selections` even when a stronger basis has + // already selected a voucher; otherwise the duplicate number can turn a + // sound identity match into a false cross-basis conflict. + let number_is_unique_in_proposal = proposal.number_key.as_deref().is_some_and(|number_key| { + proposal_number_counts + .get(&(proposal.type_key.as_str(), number_key)) + .copied() + == Some(1) + }); + if number_decides && number_is_unique_in_proposal && number_matches.len() == 1 { + selections.push((PresenceBasis::ManualVoucherNumber, number_matches[0])); + } + + if let Some(&(basis, position)) = selections.first() { + // An ambiguous narration never becomes an identity selection. It still + // contradicts any identity selecting a *different* voucher: the + // proposal's exact marker was observed on that other voucher, and + // settling would discard it. Keep that occurrence in the candidate and + // touched sets for the operator who must decide between the rows. + let ambiguous_marker_conflict = ambiguous_marker_matches + .iter() + .any(|other| *other != position); + let touched = with_resemblances( + selections + .iter() + .map(|(_, at)| *at) + .chain(ambiguous_marker_matches.iter().copied()) + .collect(), + ); + // Two selections can name the *same* book voucher by different rules + // (`REMOTEID` and the marker both landing on A while the number + // selects B): mapping every selection straight into a candidate would + // list A twice and report a `candidate_count` one higher than the + // number of book vouchers actually in play. Collapse by position to + // the strongest rule first, the same helper `resemblances` uses for + // the same reason. + let ranked = || { + let mut found: BTreeMap = BTreeMap::new(); + for &(basis, at) in &selections { + keep_strongest(&mut found, at, basis.candidate_rule()); + } + if ambiguous_marker_conflict { + for &at in &ambiguous_marker_matches { + keep_strongest(&mut found, at, CandidateRule::SharedNarrationMarker); + } + } + let mut entries = found.into_iter().collect::>(); + candidates_ranked(window, &mut entries) + }; + // Evidence that was never gathered cannot settle a `Present` either. + // This withholding precedes conflict classification: an unread column + // cannot establish that a selected voucher omitted the supplied value. + if let Some(reason) = skipped_evidence { return shell( - PresenceStatus::PossiblyPresent(undecided( - UndecidedReason::BookNumberCollision, - candidates_from(window, &number_matches, CandidateRule::SharedVoucherNumber), - )), + PresenceStatus::PossiblyPresent(undecided(reason, ranked())), 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), observed) => observed != Some(proposed), - _ => 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, - ); - } + // Two identity signals that disagree are reported, never ranked — the + // same rule ADR 0016 applies when an identifier contradicts an exact + // name. They can disagree two ways: by selecting different vouchers, + // or by agreeing on one that names a different identity than the + // proposal does. + if ambiguous_marker_conflict + || selections.iter().any(|&(_, other)| other != position) + || contradicts(proposal, &window.vouchers[position]) + { return shell( - settled(proposal, party, matched, PresenceBasis::ManualVoucherNumber), + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::IdentityConflict, + ranked(), + )), touched, ); } + return shell( + settled(proposal, party, &window.vouchers[position], basis), + touched, + ); } // Rule three: everything else is resemblance, and resemblance decides @@ -1829,12 +2151,9 @@ fn decide( if found.is_empty() { // Nothing resembled it — but an absence is only evidence when every // key this proposal carries was actually compared. - if remote_id_unverifiable { + if let Some(reason) = skipped_evidence { return shell( - PresenceStatus::PossiblyPresent(undecided( - UndecidedReason::RemoteIdEvidenceUnavailable, - (Vec::new(), 0), - )), + PresenceStatus::PossiblyPresent(undecided(reason, (Vec::new(), 0))), BTreeSet::new(), ); } @@ -1877,8 +2196,8 @@ fn decide( } let touched = found.keys().copied().collect::>(); - let reason = if remote_id_unverifiable { - UndecidedReason::RemoteIdEvidenceUnavailable + let reason = if let Some(reason) = skipped_evidence { + reason } else { match ( number_matches.is_empty(), @@ -1900,6 +2219,23 @@ fn decide( ) } +/// The selected voucher names a different identity than the proposal does. +/// A supplied observed `REMOTEID` must match the selected voucher's value; +/// absence in an observed column also contradicts it. Narration markers only +/// compare when both sides supply one, because their absence is not a claim +/// about the external source identity. +fn contradicts(proposal: &ProposedVoucher, voucher: &BookVoucher) -> bool { + let remote_id_disagrees = proposal + .remote_id + .as_deref() + .is_some_and(|proposed| voucher.remote_id.as_deref() != Some(proposed)); + let marker_disagrees = matches!( + (proposal.narration_marker.as_deref(), voucher.marker.as_deref()), + (Some(proposed), Some(observed)) if proposed != observed + ); + remote_id_disagrees || marker_disagrees +} + /// Every book voucher this proposal resembles, strongest rule per voucher. /// /// Extracted because the *touched* set it produces is needed even on paths that @@ -1920,6 +2256,20 @@ fn resemblances( for position in number_matches { keep_strongest(&mut found, *position, CandidateRule::SharedVoucherNumber); } + // A voucher whose narration carried this proposal's marker *and* another + // one cannot be an identity -- it claims two imports. It is still the + // strongest resemblance there is, and withholding the absence is the whole + // point: the marker was observed, so `Absent` is not available. + if let Some(marker) = proposal.narration_marker.as_deref() { + for position in index + .by_ambiguous_marker + .get(marker) + .map(Vec::as_slice) + .unwrap_or_default() + { + keep_strongest(&mut found, *position, CandidateRule::SharedNarrationMarker); + } + } let mut pool: BTreeSet = BTreeSet::new(); if let Some(positions) = index.by_date.get(proposal.date()) { pool.extend(positions.iter().copied()); @@ -1963,10 +2313,7 @@ fn settled( ( vec![PresenceCandidate { book_key: voucher.key().to_string(), - rule: match basis { - PresenceBasis::RemoteId => CandidateRule::SharedRemoteId, - PresenceBasis::ManualVoucherNumber => CandidateRule::SharedVoucherNumber, - }, + rule: basis.candidate_rule(), }], 1, ), @@ -2105,10 +2452,15 @@ fn observe( unmatched_book_vouchers, window_voucher_count: window.vouchers.len(), remote_id_observed: !index.by_remote_id.is_empty(), + narration_marker_observed: !index.by_marker.is_empty(), + unidentified_bridge_writes: window + .vouchers + .iter() + .filter(|voucher| voucher.unidentified_bridge_write) + .count(), } } -/// 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* @@ -2202,18 +2554,6 @@ fn candidates_ranked( (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( reason: UndecidedReason, (candidates, found): (Vec, 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 edd9409b..f4df6577 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 @@ -38,6 +38,7 @@ struct BookRow { remote_id: Option<&'static str>, party: Option<&'static str>, rows: Vec<[&'static str; 2]>, + marker: ObservedMarker<'static>, cancelled: bool, optional: bool, } @@ -57,6 +58,7 @@ impl BookRow { ["Output CGST 9%", "900.00"], ["Output SGST 9%", "900.00"], ], + marker: ObservedMarker::Absent, cancelled: false, optional: false, } @@ -90,6 +92,23 @@ impl BookRow { self } + fn marker(mut self, marker: &'static str) -> Self { + self.marker = ObservedMarker::Identifying(marker); + self + } + + fn unidentified_marker(mut self) -> Self { + self.marker = ObservedMarker::Unidentified(&[]); + self + } + + /// A narration carrying more than one well-formed marker: it identifies + /// nothing, and the occurrences are still evidence. + fn ambiguous_markers(mut self, markers: &'static [&'static str]) -> Self { + self.marker = ObservedMarker::Unidentified(markers); + self + } + fn cancelled(mut self) -> Self { self.cancelled = true; self @@ -109,6 +128,7 @@ impl BookRow { voucher_number: self.number, remote_id: self.remote_id, party: self.party, + marker: self.marker, entries: &entries, cancelled: self.cancelled, optional: self.optional, @@ -124,6 +144,7 @@ struct ProposalRow { number: Option<&'static str>, remote_id: Option<&'static str>, party: Option<&'static str>, + marker: Option<&'static str>, rows: Vec<[&'static str; 2]>, } @@ -136,6 +157,7 @@ impl ProposalRow { number: Some(number), remote_id: None, party: Some("Alpha Traders"), + marker: None, rows: vec![ ["Alpha Traders", "-11800.00"], ["Sales Account", "10000.00"], @@ -161,6 +183,11 @@ impl ProposalRow { self } + fn marker(mut self, marker: &'static str) -> Self { + self.marker = Some(marker); + self + } + fn rows(mut self, rows: Vec<[&'static str; 2]>) -> Self { self.rows = rows; self @@ -174,6 +201,7 @@ impl ProposalRow { voucher_type: self.voucher_type, voucher_number: self.number, remote_id: self.remote_id, + narration_marker: self.marker, party: self.party, entries: &entries, }) @@ -182,13 +210,14 @@ impl ProposalRow { } fn window(rows: &[BookRow]) -> BookWindow { - BookWindow::observed( - "20260801", - "20260831", - WindowRead::Complete, - RemoteIdEvidence::Observed, - rows.iter().map(BookRow::build).collect(), - ) + BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::Observed, + vouchers: rows.iter().map(BookRow::build).collect(), + }) .expect("window") } @@ -240,13 +269,14 @@ fn reason(entry: &VoucherPresence) -> UndecidedReason { /// `decide` needs it every time it would otherwise settle `Absent`. #[test] 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(), - ) + let window = BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Partial, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::Observed, + vouchers: Vec::new(), + }) .expect("a partial read is still a window"); assert_eq!(window.read(), WindowRead::Partial); } @@ -269,13 +299,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, - RemoteIdEvidence::Observed, - vec![outside] - ) + BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::Observed, + vouchers: vec![outside], + }) .expect_err("outside"), PresenceError::WindowVoucherOutsideRange ); @@ -288,13 +319,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, - RemoteIdEvidence::Observed, - rows - ) + BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::Observed, + vouchers: rows, + }) .expect_err("duplicate"), PresenceError::WindowDuplicateVoucherKey ); @@ -303,13 +335,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, - RemoteIdEvidence::Observed, - Vec::new() - ) + BookWindow::observed(ObservedWindow { + from: "20260831", + to: "20260801", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::Observed, + vouchers: Vec::new(), + }) .expect_err("inverted"), PresenceError::WindowRangeInvalid ); @@ -415,6 +448,33 @@ fn numbering_declarations_bound_aggregate_bytes_while_consuming_duplicates() { ); } +#[test] +fn aggregate_proposal_window_resemblance_work_is_refused() { + let rows = (0..1_001) + .map(|i| { + let key = Box::leak(format!("book-{i}").into_boxed_str()); + BookRow::new(key, "20260812", "AA0118") + }) + .collect::>(); + let proposals = (0..1_001) + .map(|i| { + let number = Box::leak(format!("AA{i:04}").into_boxed_str()); + ProposalRow::new(i, "20260812", number).build() + }) + .collect::>(); + let observed = window(&rows); + assert_eq!( + PresenceRequest::new( + &observed, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ) + .expect_err("aggregate comparison work is bounded"), + PresenceError::ComparisonWorkTooLarge + ); +} + #[test] fn admitted_indexed_work_boundary_is_accepted() { let rows = (0..500) @@ -1097,6 +1157,7 @@ fn an_empty_proposal_entry_list_is_refused_at_the_core_boundary() { voucher_type: "Sales", voucher_number: Some("AA0118"), remote_id: None, + narration_marker: None, party: None, entries: &[], }) @@ -1340,39 +1401,6 @@ 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 weighted_party_fanout_is_bounded_below_the_pair_product_limit() { let names = (0..25) @@ -1387,17 +1415,9 @@ fn weighted_party_fanout_is_bounded_below_the_pair_product_limit() { ) .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 observed = window(&rows); let proposals = (0..500) .map(|i| ProposalRow::new(i, "20260812", "P").party("Party").build()) .collect::>(); @@ -1473,6 +1493,7 @@ 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 { + marker: ObservedMarker::Absent, key: "book-1", date: "20260812", voucher_type: "Sales", @@ -1484,11 +1505,17 @@ fn observed_input_refuses_blank_unsafe_and_invalid_fields() { optional: false, }; assert_eq!( - BookVoucher::observed(ObservedVoucher { key: " ", ..base }).expect_err("blank"), + BookVoucher::observed(ObservedVoucher { + marker: ObservedMarker::Absent, + key: " ", + ..base + }) + .expect_err("blank"), PresenceError::TextBlank ); assert_eq!( BookVoucher::observed(ObservedVoucher { + marker: ObservedMarker::Absent, voucher_type: "Sales\u{0007}", ..base }) @@ -1497,6 +1524,7 @@ fn observed_input_refuses_blank_unsafe_and_invalid_fields() { ); assert_eq!( BookVoucher::observed(ObservedVoucher { + marker: ObservedMarker::Absent, date: "2026-08-12", ..base }) @@ -1507,6 +1535,7 @@ fn observed_input_refuses_blank_unsafe_and_invalid_fields() { let bad = entries(&bad); assert_eq!( BookVoucher::observed(ObservedVoucher { + marker: ObservedMarker::Absent, entries: &bad, ..base }) @@ -1536,6 +1565,7 @@ fn a_window_bounds_aggregate_ledger_memberships_before_indexing() { voucher_number: None, remote_id: None, party: None, + marker: ObservedMarker::Absent, entries: &entries, cancelled: false, optional: false, @@ -1544,13 +1574,14 @@ fn a_window_bounds_aggregate_ledger_memberships_before_indexing() { }) .collect(); assert_eq!( - BookWindow::observed( - "20260801", - "20260831", - WindowRead::Complete, - RemoteIdEvidence::Observed, + BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::Observed, vouchers, - ) + }) .expect_err("derived index membership budget"), PresenceError::WindowLedgerMembershipsTooMany ); @@ -1564,23 +1595,25 @@ fn raw_observations_are_bounded_before_voucher_conversion() { }; let rows = vec![entry; MAX_WINDOW_RAW_ENTRY_WORK + 1]; assert_eq!( - BookWindow::from_observations( - "20260801", - "20260831", - WindowRead::Complete, - RemoteIdEvidence::Observed, - [ObservedVoucher { + BookWindow::from_observations(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::NotRead, + vouchers: [ObservedVoucher { key: "book-1", date: "20260812", voucher_type: "Sales", voucher_number: None, remote_id: None, party: None, + marker: ObservedMarker::Absent, entries: &rows, cancelled: false, optional: false }], - ) + }) .expect_err("raw entries must be refused before parsing"), PresenceError::WindowRawEntryWorkTooLarge ); @@ -1590,23 +1623,25 @@ fn raw_observations_are_bounded_before_voucher_conversion() { amount: "1.00", }]; assert_eq!( - BookWindow::from_observations( - "20260801", - "20260831", - WindowRead::Complete, - RemoteIdEvidence::Observed, - [ObservedVoucher { + BookWindow::from_observations(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::NotRead, + vouchers: [ObservedVoucher { key: "book-2", date: "20260812", voucher_type: "Sales", voucher_number: None, remote_id: None, party: None, + marker: ObservedMarker::Absent, entries: &oversized, cancelled: false, optional: false }], - ) + }) .expect_err("raw bytes must be refused before cloning"), PresenceError::WindowRawEntryBytesTooLarge ); @@ -1623,17 +1658,19 @@ fn raw_observations_are_bounded_before_voucher_conversion() { voucher_number: None, remote_id: None, party: None, + marker: ObservedMarker::Absent, entries, cancelled: false, optional: false, }); - assert!(BookWindow::from_observations( - "20260801", - "20260831", - WindowRead::Complete, - RemoteIdEvidence::Observed, - admitted - ) + assert!(BookWindow::from_observations(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::NotRead, + vouchers: admitted, + }) .is_ok()); } @@ -1652,6 +1689,7 @@ fn raw_entry_work_is_bounded_across_valid_voucher_sized_rows() { voucher_number: None, remote_id: None, party: None, + marker: ObservedMarker::Absent, entries: &full_voucher_entries, cancelled: false, optional: false, @@ -1664,18 +1702,20 @@ fn raw_entry_work_is_bounded_across_valid_voucher_sized_rows() { voucher_number: None, remote_id: None, party: None, + marker: ObservedMarker::Absent, entries: std::slice::from_ref(&entry), cancelled: false, optional: false, }); assert_eq!( - BookWindow::from_observations( - "20260801", - "20260831", - WindowRead::Complete, - RemoteIdEvidence::Observed, - rows, - ) + BookWindow::from_observations(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::NotRead, + vouchers: rows, + }) .expect_err("the aggregate raw-entry limit must span valid rows"), PresenceError::WindowRawEntryWorkTooLarge ); @@ -1699,6 +1739,7 @@ fn raw_entry_bytes_admit_exact_limit_and_refuse_the_next_byte() { voucher_number: None, remote_id: None, party: None, + marker: ObservedMarker::Absent, entries: &exact_entries[..MAX_ENTRIES_PER_VOUCHER], cancelled: false, optional: false, @@ -1710,6 +1751,7 @@ fn raw_entry_bytes_admit_exact_limit_and_refuse_the_next_byte() { voucher_number: None, remote_id: None, party: None, + marker: ObservedMarker::Absent, entries: &exact_entries[MAX_ENTRIES_PER_VOUCHER..2 * MAX_ENTRIES_PER_VOUCHER], cancelled: false, optional: false, @@ -1721,18 +1763,20 @@ fn raw_entry_bytes_admit_exact_limit_and_refuse_the_next_byte() { voucher_number: None, remote_id: None, party: None, + marker: ObservedMarker::Absent, entries: &exact_entries[2 * MAX_ENTRIES_PER_VOUCHER..], cancelled: false, optional: false, }, ]; - assert!(BookWindow::from_observations( - "20260801", - "20260831", - WindowRead::Complete, - RemoteIdEvidence::Observed, - exact_rows, - ) + assert!(BookWindow::from_observations(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::NotRead, + vouchers: exact_rows, + }) .is_ok()); let extra = ObservedEntry { ledger: ledger_1024, @@ -1757,13 +1801,14 @@ fn raw_entry_bytes_admit_exact_limit_and_refuse_the_next_byte() { MAX_WINDOW_RAW_ENTRY_BYTES + 1, ); assert_eq!( - BookWindow::from_observations( - "20260801", - "20260831", - WindowRead::Complete, - RemoteIdEvidence::Observed, - over_rows, - ) + BookWindow::from_observations(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::NotRead, + vouchers: over_rows, + }) .expect_err("one byte over the aggregate raw-byte limit must refuse"), PresenceError::WindowRawEntryBytesTooLarge ); @@ -1805,24 +1850,233 @@ fn a_window_bounds_aggregate_ledger_key_bytes_before_indexing() { voucher_number: None, remote_id: None, party: None, + marker: ObservedMarker::Absent, entries: &entries, cancelled: false, optional: false, }) .expect("voucher below its own bounds"); assert_eq!( - BookWindow::observed( - "20260801", - "20260831", - WindowRead::Complete, - RemoteIdEvidence::Observed, - vec![voucher], - ) + BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::Observed, + vouchers: vec![voucher], + }) .expect_err("derived index key-byte budget"), PresenceError::WindowLedgerKeyBytesTooLarge ); } +fn ambiguous_marker_voucher( + voucher_position: usize, + marker_count: usize, + entries: &[ObservedEntry<'static>], +) -> BookVoucher { + let markers = Box::leak( + (0..marker_count) + .map(|marker_position| { + Box::leak( + format!("marker-{voucher_position:04}-{marker_position:02}").into_boxed_str(), + ) as &'static str + }) + .collect::>() + .into_boxed_slice(), + ) as &'static [&'static str]; + BookVoucher::observed(ObservedVoucher { + key: Box::leak(format!("book-{voucher_position:04}").into_boxed_str()), + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + marker: ObservedMarker::Unidentified(markers), + entries, + cancelled: false, + optional: false, + }) + .expect("voucher below its own ambiguous-marker bound") +} + +#[test] +fn an_ambiguous_narration_bounds_raw_occurrences_before_cloning() { + let entries = [ + ObservedEntry { + ledger: "Alpha Traders", + amount: "-1.00", + }, + ObservedEntry { + ledger: "Sales Account", + amount: "1.00", + }, + ]; + let at_limit = Box::leak( + (0..MAX_AMBIGUOUS_MARKERS_PER_VOUCHER) + .map(|_| "marker") + .collect::>() + .into_boxed_slice(), + ) as &'static [&'static str]; + assert!( + BookVoucher::observed(ObservedVoucher { + key: "book-at-limit", + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + marker: ObservedMarker::Unidentified(at_limit), + entries: &entries, + cancelled: false, + optional: false, + }) + .is_ok(), + "the declared raw marker limit remains admitted" + ); + let over_limit = Box::leak( + (0..=MAX_AMBIGUOUS_MARKERS_PER_VOUCHER) + .map(|_| "marker") + .collect::>() + .into_boxed_slice(), + ) as &'static [&'static str]; + assert_eq!( + BookVoucher::observed(ObservedVoucher { + key: "book-over-limit", + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + marker: ObservedMarker::Unidentified(over_limit), + entries: &entries, + cancelled: false, + optional: false, + }) + .expect_err("raw marker count before a set clone"), + PresenceError::TooManyAmbiguousMarkers + ); +} + +#[test] +fn a_window_bounds_aggregate_ambiguous_marker_memberships_before_indexing() { + let entries = [ + ObservedEntry { + ledger: "Alpha Traders", + amount: "-1.00", + }, + ObservedEntry { + ledger: "Sales Account", + amount: "1.00", + }, + ]; + let full_vouchers = MAX_WINDOW_AMBIGUOUS_MARKER_MEMBERSHIPS / MAX_AMBIGUOUS_MARKERS_PER_VOUCHER; + let remainder = MAX_WINDOW_AMBIGUOUS_MARKER_MEMBERSHIPS % MAX_AMBIGUOUS_MARKERS_PER_VOUCHER; + let mut vouchers = (0..full_vouchers) + .map(|voucher_position| { + ambiguous_marker_voucher( + voucher_position, + MAX_AMBIGUOUS_MARKERS_PER_VOUCHER, + &entries, + ) + }) + .collect::>(); + if remainder > 0 { + vouchers.push(ambiguous_marker_voucher(full_vouchers, remainder, &entries)); + } + assert_eq!( + vouchers + .iter() + .map(|voucher| voucher.ambiguous_markers.len()) + .sum::(), + MAX_WINDOW_AMBIGUOUS_MARKER_MEMBERSHIPS, + "fixture reaches the aggregate membership boundary" + ); + assert!(BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::Observed, + vouchers: vouchers.clone(), + }) + .is_ok()); + vouchers.push(ambiguous_marker_voucher(full_vouchers + 1, 1, &entries)); + assert_eq!( + BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::Observed, + vouchers, + }) + .expect_err("derived marker membership budget"), + PresenceError::WindowAmbiguousMarkerMembershipsTooMany + ); +} + +#[test] +fn a_window_bounds_aggregate_ambiguous_marker_key_bytes_before_indexing() { + let entries = [ + ObservedEntry { + ledger: "Alpha Traders", + amount: "-1.00", + }, + ObservedEntry { + ledger: "Sales Account", + amount: "1.00", + }, + ]; + let vouchers: Vec<_> = (0..(MAX_WINDOW_AMBIGUOUS_MARKER_KEY_BYTES / MAX_TEXT_CHARS + 1)) + .map(|position| { + let marker = Box::leak( + format!("{position:05}{}", "x".repeat(MAX_TEXT_CHARS - 5)).into_boxed_str(), + ) as &'static str; + let markers = Box::leak(vec![marker].into_boxed_slice()) as &'static [&'static str]; + BookVoucher::observed(ObservedVoucher { + key: Box::leak(format!("book-{position:04}").into_boxed_str()), + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + marker: ObservedMarker::Unidentified(markers), + entries: &entries, + cancelled: false, + optional: false, + }) + .expect("voucher below its own marker bounds") + }) + .collect(); + let admitted = MAX_WINDOW_AMBIGUOUS_MARKER_KEY_BYTES / MAX_TEXT_CHARS; + assert_eq!( + admitted * MAX_TEXT_CHARS, + MAX_WINDOW_AMBIGUOUS_MARKER_KEY_BYTES + ); + assert!(BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::Observed, + vouchers: vouchers[..admitted].to_vec(), + }) + .is_ok()); + assert_eq!( + BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::Observed, + vouchers, + }) + .expect_err("derived marker key-byte budget"), + PresenceError::WindowAmbiguousMarkerKeyBytesTooLarge + ); +} + #[test] fn every_error_carries_a_distinct_stable_reason_code() { let codes = [ @@ -1830,12 +2084,16 @@ fn every_error_carries_a_distinct_stable_reason_code() { PresenceError::WindowTooLarge, PresenceError::WindowLedgerMembershipsTooMany, PresenceError::WindowLedgerKeyBytesTooLarge, + PresenceError::WindowAmbiguousMarkerMembershipsTooMany, + PresenceError::WindowAmbiguousMarkerKeyBytesTooLarge, PresenceError::WindowVoucherOutsideRange, PresenceError::WindowDuplicateVoucherKey, PresenceError::WindowDoesNotCover, PresenceError::ProposalsEmpty, PresenceError::TooManyProposals, + PresenceError::ComparisonWorkTooLarge, PresenceError::TooManyEntries, + PresenceError::TooManyAmbiguousMarkers, PresenceError::NumberingMethodUndeclared, PresenceError::NumberingMethodConflict, PresenceError::TextBlank, @@ -1848,7 +2106,7 @@ fn every_error_carries_a_distinct_stable_reason_code() { .iter() .map(PresenceError::safe_reason_code) .collect::>(); - assert_eq!(codes.len(), 18); + assert_eq!(codes.len(), 22); assert!(codes.iter().all(|code| code.starts_with("presence_"))); } @@ -2150,14 +2408,14 @@ fn two_proposals_reaching_one_book_voucher_are_both_demoted() { &numbering(NumberingMethod::Manual), &proposals, ); - // The first proposal's manual number is absent from the book, so its - // observed REMOTEID cannot override that contradictory identity signal. + // The absent manual number contradicts the remote identity. The second + // proposal has the sole exact manual number and remains present. assert_eq!(report.totals().present, 1); assert_eq!( reason(&report.vouchers()[0]), UndecidedReason::IdentityConflict ); - assert!(report.vouchers()[1].present_book_key().is_some()); + assert_eq!(report.vouchers()[1].present_book_key(), Some("book-1")); } #[test] @@ -2233,36 +2491,20 @@ 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] 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") + let unread = BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::NotRead, + narration_evidence: ColumnEvidence::Observed, + vouchers: vec![BookRow::new("book-1", "20260819", "AA0130") .party("Bravo Industries") .build()], - ) + }) .expect("window"); let proposals = [ProposalRow::new(0, "20260812", "AA0777") .remote_id("tally-1") @@ -2316,15 +2558,16 @@ fn the_same_proposal_is_absent_when_the_window_did_read_remote_ids() { #[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") + let partial = BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Partial, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::Observed, + vouchers: 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") @@ -2373,13 +2616,14 @@ fn the_same_proposal_is_absent_against_the_same_contents_read_completely() { #[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()], - ) + let unread = BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::NotRead, + narration_evidence: ColumnEvidence::Observed, + vouchers: vec![BookRow::new("book-1", "20260812", "AA0118").build()], + }) .expect("window"); let proposals = [ProposalRow::new(0, "20260812", "AA0777") .remote_id("tally-1") @@ -2399,15 +2643,45 @@ fn unread_remote_id_outranks_resemblance_but_keeps_its_candidates() { assert_eq!(report.observations().unmatched_book_vouchers, 0); } +#[test] +fn unread_narration_outranks_resemblance_but_keeps_its_candidates() { + let unread = BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::NotRead, + vouchers: vec![BookRow::new("book-1", "20260812", "AA0118").build()], + }) + .expect("window"); + let proposals = [ProposalRow::new(0, "20260812", "AA0777") + .marker("marker-1") + .build()]; + let report = run( + &unread, + &catalog(), + &numbering(NumberingMethod::Automatic), + &proposals, + ); + let entry = only(&report); + assert_eq!(reason(entry), UndecidedReason::MarkerEvidenceUnavailable); + 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()], - ) + let unread = BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::NotRead, + narration_evidence: ColumnEvidence::Observed, + vouchers: vec![BookRow::new("book-1", "20260812", "AA0118").build()], + }) .expect("window"); let proposals = [ProposalRow::new(0, "20260812", "AA0118") .remote_id("tally-1") @@ -2430,13 +2704,14 @@ fn unread_remote_id_outranks_nondecisive_number_candidates() { #[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()], - ) + let unread = BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::NotRead, + narration_evidence: ColumnEvidence::Observed, + vouchers: vec![BookRow::new("book-1", "20260812", "AA0118").build()], + }) .expect("window"); let proposals = [ProposalRow::new(0, "20260812", "AA0118") .voucher_type("Receipt") @@ -2567,13 +2842,14 @@ fn a_voucher_with_no_party_field_has_nothing_to_disagree_with() { /// 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()], - ) + let unread = BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::NotRead, + narration_evidence: ColumnEvidence::Observed, + vouchers: vec![BookRow::new("book-1", "20260812", "AA0118").build()], + }) .expect("window"); let proposals = [ProposalRow::new(0, "20260812", "AA0118") .remote_id("tally-1") @@ -2596,13 +2872,14 @@ fn a_number_match_cannot_settle_while_the_proposals_remote_id_is_unread() { #[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()], - ) + let unread = BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::NotRead, + narration_evidence: ColumnEvidence::Observed, + vouchers: vec![BookRow::new("book-1", "20260812", "AA0118").build()], + }) .expect("window"); let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; let report = run( @@ -2650,33 +2927,101 @@ fn a_remote_id_and_a_number_selecting_different_vouchers_do_not_settle() { } #[test] -fn a_remote_id_with_an_absent_manual_number_on_an_unobserved_type_is_a_conflict() { +fn a_remote_id_with_an_absent_manual_number_is_an_identity_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 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_nonunique_proposal_number_cannot_contradict_a_unique_remote_id() { - let window = window(&[ - BookRow::new("book-1", "20260812", "AA0118").remote_id("tally-1"), - BookRow::new("book-2", "20260813", "AA0119"), - ]); - let proposals = [ - ProposalRow::new(0, "20260813", "AA0119") - .remote_id("tally-1") - .build(), +fn a_marker_with_an_absent_manual_number_is_an_identity_conflict() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").marker("marker-1")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999") + .marker("marker-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); + let candidates = &entry.undecided().expect("undecided").candidates; + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].book_key, "book-1"); + assert_eq!(candidates[0].rule, CandidateRule::SharedNarrationMarker); +} + +#[test] +fn an_absent_manual_number_conflict_retains_both_strong_identities() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118").remote_id("remote-1"), + BookRow::new("book-2", "20260813", "AA0119").marker("marker-1"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999") + .remote_id("remote-1") + .marker("marker-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); + 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::SharedNarrationMarker)); + assert_eq!(report.observations().unmatched_book_vouchers, 0); +} + +#[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(&[ + BookRow::new("book-1", "20260812", "AA0118").remote_id("tally-1"), + BookRow::new("book-2", "20260813", "AA0119"), + ]); + let proposals = [ + ProposalRow::new(0, "20260813", "AA0119") + .remote_id("tally-1") + .build(), ProposalRow::new(1, "20260814", "AA0119").build(), ]; let report = run( @@ -2799,26 +3144,28 @@ fn a_window_declaring_remote_ids_unread_refuses_to_carry_one() { .remote_id("tally-1") .build()]; assert_eq!( - BookWindow::observed( - "20260801", - "20260831", - WindowRead::Complete, - RemoteIdEvidence::NotRead, - carrying, - ) + BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::NotRead, + narration_evidence: ColumnEvidence::Observed, + vouchers: 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") + assert!(BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::Observed, + vouchers: vec![BookRow::new("book-1", "20260812", "AA0118") .remote_id("tally-1") .build()], - ) + }) .is_ok()); } @@ -3052,6 +3399,11 @@ fn this_contracts_files_are_still_pinned_in_the_compatibility_surface() { // 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", + // A narration marker is whatever this derives (ADR 0018). Presence + // calls the same function the import writer calls so that a reader and + // a writer cannot disagree about one voucher's identity -- which makes + // an edit confined to it a silent change to what is reported present. + "src-tauri/src/agent_import_identity.rs", ] { assert!( pinned.contains(path), @@ -3111,6 +3463,7 @@ fn a_pathological_book_key_is_refused_rather_than_truncated() { let long: String = "g".repeat(MAX_BOOK_KEY_CHARS + 1); assert_eq!( BookVoucher::observed(ObservedVoucher { + marker: ObservedMarker::Absent, key: &long, date: "20260812", voucher_type: "Sales", @@ -3126,6 +3479,7 @@ fn a_pathological_book_key_is_refused_rather_than_truncated() { ); // A real Tally GUID — company prefix plus master id — is far inside it. assert!(BookVoucher::observed(ObservedVoucher { + marker: ObservedMarker::Absent, key: "61c6de69-1748-461c-ad3f-162cb949df9f-00000001", date: "20260812", voucher_type: "Sales", @@ -3393,6 +3747,558 @@ fn a_number_collision_still_reaches_what_it_only_resembled() { ); } +// --------------------------------------------------------------------------- +// ADR 0018 — the narration marker as an identity basis. +// +// Markers here are canonical-looking opaque strings. This crate never parses +// one: the `[BRIDGE:...]` convention is applied above it, which is the whole +// reason the crate can treat them as hashable keys. +// --------------------------------------------------------------------------- + +const MARKER_A: &str = "8f14e45f-ceea-467a-9c1b-7b2f4c8a0001"; +const MARKER_B: &str = "8f14e45f-ceea-467a-9c1b-7b2f4c8a0002"; + +/// A window whose read did not fetch `NARRATION` at all. +fn window_without_narration(rows: &[BookRow]) -> BookWindow { + BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::NotRead, + vouchers: rows.iter().map(BookRow::build).collect(), + }) + .expect("window") +} + +/// The case the basis exists for: Bridge wrote this voucher, and says so. +#[test] +fn a_marker_bridge_wrote_settles_a_present() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").marker(MARKER_A)]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999") + .marker(MARKER_A) + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Automatic), + &proposals, + ); + let entry = only(&report); + assert_eq!(entry.present_book_key(), Some("book-1")); + let PresenceStatus::Present { basis, .. } = &entry.status else { + panic!("expected Present"); + }; + assert_eq!(*basis, PresenceBasis::NarrationMarker); + assert!(report.observations().narration_marker_observed); +} + +/// Under `Automatic` numbering Tally discards the supplied number, so the +/// manual-number basis does not exist and `REMOTEID` is not fetched by the +/// shipped read. Without the marker this proposal has no identity at all -- +/// which is exactly the gap ADR 0018 was written to close. +#[test] +fn a_marker_decides_where_automatic_numbering_leaves_nothing_else() { + let rows = [BookRow::new("book-1", "20260812", "AA0118").marker(MARKER_A)]; + let unmarked = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let without = run( + &window(&rows), + &catalog(), + &numbering(NumberingMethod::Automatic), + &unmarked, + ); + assert_eq!( + reason(only(&without)), + UndecidedReason::NumberNotDecisive, + "the number matches the book row exactly and still decides nothing" + ); + + let marked = [ProposalRow::new(0, "20260812", "AA0118") + .marker(MARKER_A) + .build()]; + let with = run( + &window(&rows), + &catalog(), + &numbering(NumberingMethod::Automatic), + &marked, + ); + assert_eq!(only(&with).present_book_key(), Some("book-1")); +} + +/// Bridge writes a distinct identity per imported voucher, so one marker on two +/// book rows is a book anomaly. It is still never resolved by picking one. +#[test] +fn one_marker_on_two_book_vouchers_decides_nothing() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118").marker(MARKER_A), + BookRow::new("book-2", "20260813", "AA0119").marker(MARKER_A), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .marker(MARKER_A) + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert_eq!(reason(entry), UndecidedReason::NarrationMarkerCollision); + assert_eq!(entry.undecided().expect("undecided").candidate_count, 2); +} + +/// A marker that would otherwise identify one voucher uniquely is not unique +/// when a *second* book voucher also carries it, even if that second voucher +/// only carries it ambiguously. Counting solely `by_marker` let the ambiguous +/// occurrence hide: the identifying voucher looked like the marker's only +/// home, and `Present` went out for it while the marker actually named two +/// book vouchers -- the exact middle case ambiguous-marker handling exists to +/// preserve. +#[test] +fn a_marker_shared_with_an_ambiguous_voucher_decides_nothing() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118").marker(MARKER_A), + BookRow::new("book-2", "20260813", "AA0119").ambiguous_markers(&[MARKER_A, MARKER_B]), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .marker(MARKER_A) + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert_eq!(reason(entry), UndecidedReason::NarrationMarkerCollision); + assert_eq!( + entry.undecided().expect("undecided").candidate_count, + 2, + "the marker occurs on two book vouchers, so it is not unique" + ); +} + +/// An occurrence carried by an ambiguous narration is not an identity by +/// itself, but it still contradicts a manual number that selected another row. +/// Both rows must remain candidates and reached evidence for the operator. +#[test] +fn an_ambiguous_marker_on_another_voucher_blocks_a_manual_number_settlement() { + let window = window(&[ + BookRow::new("book-a", "20260812", "AA0118"), + BookRow::new("book-b", "20260813", "BB0229").ambiguous_markers(&[MARKER_A, MARKER_B]), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .marker(MARKER_A) + .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); + let undecided = entry.undecided().expect("identity conflict"); + assert_eq!(undecided.candidate_count, 2); + assert_eq!( + undecided + .candidates + .iter() + .map(|candidate| candidate.book_key.as_str()) + .collect::>(), + vec!["book-b", "book-a"], + "marker and number evidence both remain visible" + ); + assert_eq!( + undecided.candidates[0].rule, + CandidateRule::SharedNarrationMarker + ); + assert_eq!( + undecided.candidates[1].rule, + CandidateRule::SharedVoucherNumber + ); + assert_eq!(report.observations().unmatched_book_vouchers, 0); +} + +/// Proposal-side uniqueness is checked before the book lookup, the same way it +/// is for a `REMOTEID`: two source rows claiming one identity are undecidable +/// whether or not the book holds it. +#[test] +fn two_proposals_claiming_one_marker_decide_nothing() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").marker(MARKER_A)]); + let proposals = [ + ProposalRow::new(0, "20260812", "AA0118") + .marker(MARKER_A) + .build(), + ProposalRow::new(1, "20260813", "AA0119") + .marker(MARKER_A) + .build(), + ]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + for entry in report.vouchers() { + assert_eq!(reason(entry), UndecidedReason::NarrationMarkerCollision); + } +} + +#[test] +fn a_remote_id_collision_retains_each_narration_marker_match() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118").marker(MARKER_A), + BookRow::new("book-2", "20260813", "AA0119").marker(MARKER_B), + ]); + let proposals = [ + ProposalRow::new(0, "20260812", "AA0118") + .remote_id("shared-source-id") + .marker(MARKER_A) + .build(), + ProposalRow::new(1, "20260813", "AA0119") + .remote_id("shared-source-id") + .marker(MARKER_B) + .build(), + ]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Automatic), + &proposals, + ); + for (entry, expected_key) in report.vouchers().iter().zip(["book-1", "book-2"]) { + assert_eq!(reason(entry), UndecidedReason::RemoteIdCollision); + let undecided = entry.undecided().expect("collision"); + assert_eq!(undecided.candidate_count, 1); + assert_eq!(undecided.candidates[0].book_key, expected_key); + assert_eq!( + undecided.candidates[0].rule, + CandidateRule::SharedNarrationMarker + ); + } + assert_eq!(report.observations().unmatched_book_vouchers, 0); +} + +/// Three identity signals mean three ways to disagree. A marker selecting one +/// voucher while the manual number selects another is reported, never ranked. +#[test] +fn a_marker_and_a_number_selecting_different_vouchers_conflict() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118").marker(MARKER_A), + BookRow::new("book-2", "20260813", "AA0777"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0777") + .marker(MARKER_A) + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert_eq!(reason(entry), UndecidedReason::IdentityConflict); + let keys = entry + .undecided() + .expect("undecided") + .candidates + .iter() + .map(|candidate| candidate.book_key.as_str()) + .collect::>(); + assert!( + keys.contains("book-1") && keys.contains("book-2"), + "both sides of the disagreement are shown, not just the stronger one" + ); +} + +/// `REMOTEID` and the marker can both select the *same* book voucher while the +/// manual number selects a different one -- three selections naming only two +/// book vouchers. Mapping every selection straight into a candidate lists the +/// shared voucher twice and reports one candidate more than there are book +/// vouchers to look at; the response must collapse to book position first. +#[test] +fn a_conflict_naming_one_voucher_twice_reports_it_once() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118") + .remote_id("bridge-txn-1") + .marker(MARKER_A), + BookRow::new("book-2", "20260813", "AA0777"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0777") + .remote_id("bridge-txn-1") + .marker(MARKER_A) + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert_eq!(reason(entry), UndecidedReason::IdentityConflict); + let undecided = entry.undecided().expect("undecided"); + assert_eq!( + undecided.candidate_count, 2, + "book-1 is named by two rules but is one book voucher" + ); + let keys = undecided + .candidates + .iter() + .map(|candidate| candidate.book_key.as_str()) + .collect::>(); + assert_eq!( + keys, + vec!["book-1", "book-2"], + "each book voucher is listed exactly once" + ); +} + +/// The other way two identities disagree: they agree on one voucher, and that +/// voucher names a different marker than the proposal does. +#[test] +fn a_number_match_naming_another_marker_is_a_conflict() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").marker(MARKER_B)]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .marker(MARKER_A) + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(reason(only(&report)), UndecidedReason::IdentityConflict); +} + +/// The same rule as `RemoteIdEvidenceUnavailable`, other column. A number that +/// is decisive on its own terms still cannot settle while the evidence that +/// could have contradicted it was never gathered. +#[test] +fn a_marker_against_an_unread_narration_withholds_both_verdicts() { + let window = window_without_narration(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .marker(MARKER_A) + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!( + reason(only(&report)), + UndecidedReason::MarkerEvidenceUnavailable + ); + + // And an absence is withheld too, on a window holding nothing like it. + let empty = window_without_narration(&[]); + let away = [ProposalRow::new(0, "20260820", "ZZ9999") + .marker(MARKER_A) + .build()]; + let report = run( + &empty, + &catalog(), + &numbering(NumberingMethod::Manual), + &away, + ); + assert_eq!( + reason(only(&report)), + UndecidedReason::MarkerEvidenceUnavailable + ); +} + +/// A window cannot both say narration was never read and carry something read +/// out of a narration. The two statements contradict. +#[test] +fn a_window_that_did_not_read_narration_cannot_carry_a_marker() { + for row in [ + BookRow::new("book-1", "20260812", "AA0118").marker(MARKER_A), + BookRow::new("book-1", "20260812", "AA0118").unidentified_marker(), + ] { + let error = BookWindow::observed(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::NotRead, + vouchers: vec![row.build()], + }) + .expect_err("contradiction"); + assert_eq!(error, PresenceError::WindowNarrationContradiction); + } +} + +/// A legacy-scheme marker, two markers, or a malformed one cannot name a single +/// import, so it never matches. Losing the fact that Bridge wrote the row would +/// be its own defect, so it is counted for a person instead. +#[test] +fn an_unidentifiable_marker_is_counted_and_never_matched() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118").unidentified_marker(), + BookRow::new("book-2", "20260820", "AA0119").unidentified_marker(), + ]); + let proposals = [ProposalRow::new(0, "20260805", "AA0777") + .marker(MARKER_A) + .party("Bravo Industries") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!( + only(&report).status, + PresenceStatus::Absent, + "an unidentifiable marker matches nothing, and the window was read" + ); + let observations = report.observations(); + assert_eq!(observations.unidentified_bridge_writes, 2); + assert!( + !observations.narration_marker_observed, + "nothing identifying was observed, which is a different fact" + ); +} + +/// A marker landing on a cancelled voucher is the same case as any other +/// identity landing on one: it occupies the row without being posted. +#[test] +fn a_marker_on_a_cancelled_voucher_is_not_present() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118") + .marker(MARKER_A) + .cancelled()]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .marker(MARKER_A) + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!( + reason(only(&report)), + UndecidedReason::MatchedVoucherNotPosted + ); +} + +/// One book voucher satisfies at most one proposal *across* bases. Adding a +/// third basis adds a third way for two proposals to reach one row. +#[test] +fn a_marker_and_a_number_cannot_claim_one_voucher_for_two_proposals() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").marker(MARKER_A)]); + let mut marker_only = ProposalRow::new(1, "20260813", "AA0119").marker(MARKER_A); + // Omitted number evidence permits another identity to settle. A supplied + // absent manual number would instead conflict before claiming this row. + marker_only.number = None; + let proposals = [ + ProposalRow::new(0, "20260812", "AA0118").build(), + marker_only.build(), + ]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + // A demotion is not a licence to misdescribe what matched: the proposal + // that reached this voucher by its marker must still say so, and the one + // that reached it by its number must say that. + let rules = report + .vouchers() + .iter() + .map(|entry| { + assert_eq!(reason(entry), UndecidedReason::BookVoucherClaimedTwice); + entry.undecided().expect("undecided").candidates[0].rule + }) + .collect::>(); + assert_eq!( + rules, + vec![ + CandidateRule::SharedVoucherNumber, + CandidateRule::SharedNarrationMarker + ] + ); +} + +/// A voucher whose narration carries this proposal's marker *and* another one +/// cannot be an identity -- it claims two imports, which this contract never +/// resolves. But the marker was observed, so `Absent` is not available either: +/// saying it invites the duplicate the whole contract exists to prevent. +/// +/// The proposal here shares nothing else with the book row -- different date, +/// different number, different party, different amount -- so the marker is the +/// only thing that can surface it, and before this it surfaced nothing. +#[test] +fn an_ambiguous_narration_still_withholds_the_absence() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118").ambiguous_markers(&[MARKER_A, MARKER_B]) + ]); + let proposals = [ProposalRow::new(0, "20260820", "ZZ9999") + .marker(MARKER_A) + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-4200.00"], + ["Sales Account", "4200.00"], + ]) + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert_ne!( + entry.status, + PresenceStatus::Absent, + "the marker was observed in this book, so an absence is not available" + ); + // And it is a candidate, named by the rule that found it -- never a + // `Present`, because the voucher claims two imports. + let undecided = entry.undecided().expect("undecided"); + assert_eq!(undecided.candidates[0].book_key, "book-1"); + assert_eq!( + undecided.candidates[0].rule, + CandidateRule::SharedNarrationMarker + ); + assert_eq!(report.observations().unidentified_bridge_writes, 1); +} + +/// A `Present` on the marker reports its differences like any other basis -- +/// the ₹36.13 case reached through the channel Bridge actually has. +#[test] +fn a_marker_present_still_reports_what_disagrees() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118") + .marker(MARKER_A) + .rows(vec![ + ["Alpha Traders", "-10900.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ])]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .marker(MARKER_A) + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { + differences, basis, .. + } = &only(&report).status + else { + panic!("expected Present"); + }; + assert_eq!(*basis, PresenceBasis::NarrationMarker); + assert!(differences + .iter() + .any(|difference| difference.field == DifferenceField::Amount)); +} + #[test] fn raw_proposal_budget_counts_all_entry_work_before_conversion() { let entries = vec![ @@ -3409,6 +4315,7 @@ fn raw_proposal_budget_counts_all_entry_work_before_conversion() { voucher_type: "Receipt", voucher_number: Some("1"), remote_id: None, + narration_marker: None, party: None, entries: &entries, }) @@ -3441,7 +4348,8 @@ fn raw_proposal_budget_counts_metadata_bytes_before_conversion() { ledger: "L", amount: "1", }]; - let metadata = "x".repeat(16_384 - "20260812".len() - "Receipt".len() - 1); + let marker = "m".repeat(36); + let metadata = "x".repeat(16_384 - "20260812".len() - "Receipt".len() - 1 - marker.len()); let extra_byte = format!("{metadata}x"); assert!(extra_byte.len() <= MAX_TEXT_CHARS); let inputs = (0..256) @@ -3451,6 +4359,7 @@ fn raw_proposal_budget_counts_metadata_bytes_before_conversion() { voucher_type: "Receipt", voucher_number: Some("1"), remote_id: None, + narration_marker: Some(marker.as_str()), party: Some(metadata.as_str()), entries: &rows, }) @@ -3461,6 +4370,7 @@ fn raw_proposal_budget_counts_metadata_bytes_before_conversion() { v.date.len() + v.voucher_type.len() + v.voucher_number.unwrap().len() + + v.narration_marker.unwrap().len() + v.party.unwrap().len() }) .sum::(); @@ -3493,7 +4403,8 @@ fn raw_observation_budget_counts_retained_voucher_metadata() { let keys = (0..256) .map(|position| format!("K{position:07}")) .collect::>(); - let metadata = "x".repeat(16_384 - 8 - "20260812".len() - "Receipt".len()); + let marker = "m".repeat(36); + let metadata = "x".repeat(16_384 - 8 - "20260812".len() - "Receipt".len() - marker.len()); let extra_byte = format!("{metadata}x"); assert!(extra_byte.len() <= MAX_TEXT_CHARS); let inputs = keys @@ -3505,6 +4416,7 @@ fn raw_observation_budget_counts_retained_voucher_metadata() { voucher_number: None, remote_id: Some(metadata.as_str()), party: None, + marker: ObservedMarker::Identifying(marker.as_str()), entries: &rows, cancelled: false, optional: false, @@ -3512,17 +4424,24 @@ fn raw_observation_budget_counts_retained_voucher_metadata() { .collect::>(); let total = inputs .iter() - .map(|v| v.key.len() + v.date.len() + v.voucher_type.len() + v.remote_id.unwrap().len()) + .map(|v| { + v.key.len() + + v.date.len() + + v.voucher_type.len() + + v.remote_id.unwrap().len() + + marker.len() + }) .sum::(); assert_eq!(total, MAX_WINDOW_RAW_ENTRY_BYTES); assert_eq!( - BookWindow::from_observations( - "20260801", - "20260831", - WindowRead::Complete, - RemoteIdEvidence::Observed, - inputs.iter().copied() - ) + BookWindow::from_observations(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::Observed, + vouchers: inputs.iter().copied(), + }) .expect("exact metadata limit") .vouchers() .len(), @@ -3535,17 +4454,210 @@ fn raw_observation_budget_counts_retained_voucher_metadata() { MAX_WINDOW_RAW_ENTRY_BYTES + 1 ); assert_eq!( - BookWindow::from_observations( - "20260801", - "20260831", - WindowRead::Complete, - RemoteIdEvidence::Observed, - over - ), + BookWindow::from_observations(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::Observed, + vouchers: over, + }), Err(PresenceError::WindowRawEntryBytesTooLarge) ); } +#[test] +fn raw_observation_budget_counts_ambiguous_marker_bytes_before_conversion() { + let rows = [ObservedEntry { + ledger: "L", + amount: "1", + }]; + let marker = "m".repeat(36); + let next_marker = format!("{marker}x"); + let ambiguous = [marker.as_str()]; + let next_ambiguous = [next_marker.as_str()]; + let keys = (0..256) + .map(|position| format!("K{position:07}")) + .collect::>(); + let metadata = "x".repeat(16_384 - 8 - "20260812".len() - "Receipt".len() - marker.len()); + let inputs = keys + .iter() + .map(|key| ObservedVoucher { + key, + date: "20260812", + voucher_type: "Receipt", + voucher_number: None, + remote_id: Some(metadata.as_str()), + party: None, + marker: ObservedMarker::Unidentified(&ambiguous), + entries: &rows, + cancelled: false, + optional: false, + }) + .collect::>(); + let total = inputs + .iter() + .map(|voucher| { + voucher.key.len() + + voucher.date.len() + + voucher.voucher_type.len() + + voucher.remote_id.unwrap().len() + + marker.len() + }) + .sum::(); + assert_eq!(total, MAX_WINDOW_RAW_ENTRY_BYTES); + assert_eq!( + BookWindow::from_observations(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::Observed, + vouchers: inputs.iter().copied(), + }) + .expect("exact metadata limit") + .vouchers() + .len(), + 256 + ); + let mut over = inputs; + over[255].marker = ObservedMarker::Unidentified(&next_ambiguous); + assert_eq!(total + 1, MAX_WINDOW_RAW_ENTRY_BYTES + 1); + assert_eq!( + BookWindow::from_observations(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::Observed, + narration_evidence: ColumnEvidence::Observed, + vouchers: over, + }), + Err(PresenceError::WindowRawEntryBytesTooLarge) + ); +} + +#[test] +fn raw_observation_budget_bounds_ambiguous_marker_occurrences_before_conversion() { + let rows = [ObservedEntry { + ledger: "L", + amount: "1", + }]; + let marker = "m"; + let at_limit = vec![marker; MAX_AMBIGUOUS_MARKERS_PER_VOUCHER]; + let over_limit = vec![marker; MAX_AMBIGUOUS_MARKERS_PER_VOUCHER + 1]; + let exact = ObservedVoucher { + key: "exact", + date: "20260812", + voucher_type: "Receipt", + voucher_number: None, + remote_id: None, + party: None, + marker: ObservedMarker::Unidentified(&at_limit), + entries: &rows, + cancelled: false, + optional: false, + }; + assert!(BookWindow::from_observations(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::NotRead, + narration_evidence: ColumnEvidence::Observed, + vouchers: [exact], + }) + .is_ok()); + assert_eq!( + BookWindow::from_observations(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::NotRead, + narration_evidence: ColumnEvidence::Observed, + vouchers: [ObservedVoucher { + marker: ObservedMarker::Unidentified(&over_limit), + ..exact + }], + }), + Err(PresenceError::TooManyAmbiguousMarkers) + ); +} + +#[test] +fn raw_observation_budget_bounds_aggregate_ambiguous_marker_work_before_conversion() { + let rows = [ObservedEntry { + ledger: "L", + amount: "1", + }]; + let markers = vec!["m"; MAX_AMBIGUOUS_MARKERS_PER_VOUCHER]; + let full_rows = MAX_WINDOW_AMBIGUOUS_MARKER_MEMBERSHIPS / markers.len(); + let remainder = MAX_WINDOW_AMBIGUOUS_MARKER_MEMBERSHIPS % markers.len(); + let final_markers = vec!["m"; remainder]; + let keys = (0..=full_rows + 1) + .map(|position| format!("K{position:07}")) + .collect::>(); + let mut observations = keys[..full_rows] + .iter() + .map(|key| ObservedVoucher { + key, + date: "20260812", + voucher_type: "Receipt", + voucher_number: None, + remote_id: None, + party: None, + marker: ObservedMarker::Unidentified(&markers), + entries: &rows, + cancelled: false, + optional: false, + }) + .collect::>(); + observations.push(ObservedVoucher { + key: &keys[full_rows], + date: "20260812", + voucher_type: "Receipt", + voucher_number: None, + remote_id: None, + party: None, + marker: ObservedMarker::Unidentified(&final_markers), + entries: &rows, + cancelled: false, + optional: false, + }); + observations.push(ObservedVoucher { + key: &keys[full_rows + 1], + marker: ObservedMarker::Unidentified(&markers[..1]), + ..observations[0] + }); + assert_eq!( + full_rows * markers.len() + final_markers.len(), + MAX_WINDOW_AMBIGUOUS_MARKER_MEMBERSHIPS + ); + assert_eq!( + BookWindow::from_observations(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::NotRead, + narration_evidence: ColumnEvidence::Observed, + vouchers: observations[..=full_rows].iter().copied(), + }) + .expect("exact raw ambiguous marker work") + .vouchers() + .len(), + full_rows + 1 + ); + assert_eq!( + BookWindow::from_observations(ObservedWindow { + from: "20260801", + to: "20260831", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::NotRead, + narration_evidence: ColumnEvidence::Observed, + vouchers: observations, + }), + Err(PresenceError::WindowAmbiguousMarkerMembershipsTooMany) + ); +} + #[test] fn raw_proposal_batch_stops_an_unbounded_iterator_at_the_count_limit() { let rows = [ObservedEntry { @@ -3566,6 +4678,7 @@ fn raw_proposal_batch_stops_an_unbounded_iterator_at_the_count_limit() { voucher_type: "Receipt", voucher_number: None, remote_id: None, + narration_marker: None, party: None, entries: &rows, }) @@ -3590,6 +4703,7 @@ fn proposal_batch_rejects_duplicate_source_positions_before_conversion() { voucher_type: "Receipt", voucher_number: Some("1"), remote_id: None, + narration_marker: None, party: None, entries: &rows, }, @@ -3599,6 +4713,7 @@ fn proposal_batch_rejects_duplicate_source_positions_before_conversion() { voucher_type: "Receipt", voucher_number: Some("2"), remote_id: None, + narration_marker: None, party: None, entries: &rows, }, @@ -3608,3 +4723,32 @@ fn proposal_batch_rejects_duplicate_source_positions_before_conversion() { Err(PresenceError::DuplicateProposalPosition) ); } + +#[test] +fn a_book_number_collision_retains_an_ambiguous_marker_candidate() { + let window = window(&[ + BookRow::new("book-number-a", "20260812", "AA0118"), + BookRow::new("book-number-b", "20260813", "AA0118"), + BookRow::new("book-ambiguous-marker", "20260814", "BB0229") + .ambiguous_markers(&[MARKER_A, MARKER_B]), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .marker(MARKER_A) + .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, 3); + assert!(undecided + .candidates + .iter() + .any(|candidate| candidate.book_key == "book-ambiguous-marker" + && candidate.rule == CandidateRule::SharedNarrationMarker)); + assert_eq!(report.observations().unmatched_book_vouchers, 0); +} diff --git a/src-tauri/src/agent_admission_tests.rs b/src-tauri/src/agent_admission_tests.rs index fb8612bc..1ec5f3d9 100644 --- a/src-tauri/src/agent_admission_tests.rs +++ b/src-tauri/src/agent_admission_tests.rs @@ -17,6 +17,14 @@ fn published_pattern_inventory_preserves_the_admitted_wire_shapes() { "\u{2003}ledger" )); assert!(!published_pattern_matches(NONBLANK_PATTERN, " \u{2003}\t")); + assert!(published_pattern_matches( + BRIDGE_TRANSACTION_ID_PATTERN, + "batch_20260901-1" + )); + assert!(!published_pattern_matches( + BRIDGE_TRANSACTION_ID_PATTERN, + "batch 20260901" + )); fn patterns(value: &Value, found: &mut Vec) { match value { @@ -46,7 +54,14 @@ fn published_pattern_inventory_preserves_the_admitted_wire_shapes() { patterns(&schema["inputSchema"], &mut found); found.sort(); found.dedup(); - assert_eq!(found, vec![NONBLANK_PATTERN, DATE_WIRE_PATTERN]); + assert_eq!( + found, + vec![ + NONBLANK_PATTERN, + DATE_WIRE_PATTERN, + BRIDGE_TRANSACTION_ID_PATTERN + ] + ); } #[tokio::test] diff --git a/src-tauri/src/agent_catalog.rs b/src-tauri/src/agent_catalog.rs index 639be795..47214002 100644 --- a/src-tauri/src/agent_catalog.rs +++ b/src-tauri/src/agent_catalog.rs @@ -3,6 +3,7 @@ use super::*; const NONBLANK_PATTERN: &str = r"\S"; const DATE_WIRE_PATTERN: &str = "^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"; +const BRIDGE_TRANSACTION_ID_PATTERN: &str = "^[A-Za-z0-9_-]+$"; pub(super) fn validate_tool_arguments(name: &str, args: &Value) -> Result<(), String> { let arguments = args @@ -216,10 +217,37 @@ fn published_pattern_matches(pattern: &str, text: &str) -> bool { let remainder = remainder.strip_prefix(b"-").unwrap_or(remainder); remainder.len() == 2 && remainder.iter().all(u8::is_ascii_digit) } + BRIDGE_TRANSACTION_ID_PATTERN => { + !text.is_empty() + && text + .as_bytes() + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + } _ => false, } } +/// One proposed voucher's admission contract, lifted out of the tool literal. +/// +/// Nesting it inline exhausted `json!`'s recursion budget; naming it also puts +/// the shape a caller must satisfy in one readable place. Every bound is +/// stated here once and read back by the parser rather than restated there. +fn proposed_voucher_schema() -> Value { + json!({"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"}, + "party":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"}, + // Supplied together or not at all. Presence derives the narration + // marker from these with the same function the writer used; it never + // accepts a marker the caller chose. See ADR 0018 §1. + "batch_id":{"type":"string","minLength":1,"maxLength":64,"pattern":r"\S"}, + "bridge_txn_id":{"type":"string","minLength":1,"maxLength":64,"pattern":"^[A-Za-z0-9_-]+$"}, + "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"}}}} + }}) +} + pub(super) fn tool_definitions(import_enabled: bool, writes_enabled: bool) -> Value { let mut definitions = registered_tool_definitions(import_enabled, writes_enabled); definitions @@ -333,7 +361,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. `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.", + "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; or, for a voucher Bridge wrote, the narration marker derived from the supplied `batch_id` and `bridge_txn_id` together. It neither accepts nor reads client remote identifiers. Supplying only one narration identity component is an error. The marker reaches only the current writer identity scheme; older-scheme Bridge writes stay unidentified rather than matched. 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}, @@ -341,13 +369,7 @@ pub(super) fn registered_tool_definitions(import_enabled: bool, writes_enabled: "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"}, - "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"}}}} - }}} + "vouchers":{"type":"array","minItems":1,"maxItems":presence::MAX_PRESENCE_VOUCHERS,"items":proposed_voucher_schema()} }}), ), "changed_since" => ( diff --git a/src-tauri/src/agent_import.rs b/src-tauri/src/agent_import.rs index a1c5996f..1e52c96e 100644 --- a/src-tauri/src/agent_import.rs +++ b/src-tauri/src/agent_import.rs @@ -29,7 +29,8 @@ mod cash_bank; use cash_bank::{CashBankState, LegRequirement, ObservedMasters}; #[path = "agent_import_identity.rs"] mod identity; -use identity::{import_identity, ImportIdentityScheme}; +pub(super) use identity::import_identity; +use identity::ImportIdentityScheme; #[path = "agent_import_schema.rs"] mod schema; pub(super) use schema::voucher_input_schema; @@ -293,13 +294,15 @@ impl ImportReadSource { return Err("import_verification_identity_invalid".into()); } let narration = row.narration.as_deref().unwrap_or_default(); - for (index, (start, _)) in narration.match_indices("[BRIDGE:").enumerate() { - if index > 0 { + let mut markers = narration_markers(narration); + if let Some(first) = markers.next() { + // More than one means the row claims two imports. Taking the + // first would resolve that silently, which is what the + // presence contract refuses on the same evidence. + if markers.next().is_some() { return Err("import_verification_tag_ambiguous".into()); } - let tag = narration[start..] - .strip_prefix("[BRIDGE:") - .and_then(|tail| tail.split_once(']').map(|(id, _)| id)) + let tag = first .filter(|id| valid_txn_id(id)) .ok_or_else(|| "import_verification_tag_invalid".to_string())?; if !transaction_tags.insert(tag.to_string()) { @@ -1533,7 +1536,54 @@ fn validate_import_dates_for_profile( Ok(()) } -fn valid_txn_id(value: &str) -> bool { +/// The reserved marker this module appends to every imported narration. +pub(super) const NARRATION_MARKER_PREFIX: &str = "[BRIDGE:"; + +/// Every reserved marker occurrence in a narration, in the order written. +/// +/// `None` is an occurrence that never closed -- a malformed marker is still a +/// marker, and a reader that silently dropped it would report a narration +/// Bridge plainly touched as carrying nothing. Callers decide what more than +/// one, or a malformed one, means for them; this only reports what is there. +pub(super) fn narration_markers(narration: &str) -> impl Iterator> { + narration + .match_indices(NARRATION_MARKER_PREFIX) + .map(|(start, _)| { + narration[start + NARRATION_MARKER_PREFIX.len()..] + .split_once(']') + .map(|(identity, _)| identity) + }) +} + +/// The shape `build_import_xml` generates for a batch id: `bridge-` and a +/// canonical UUID. Read at presence time for the same reason `valid_txn_id` +/// is -- a batch id the writer could not have produced cannot have written a +/// marker, so hashing it derives an identity no book holds and the run +/// reports `absent` where it should have reported bad input. +/// +/// Canonical spelling alone is not enough: it admits a nil, v1 or v7 UUID +/// that this writer -- `Uuid::new_v4()`, line below -- could never have +/// generated. Presence would hash such a value, find it in no book, and +/// report `absent` for input the writer could not have produced, which is +/// exactly the wrong answer under automatic numbering and invites a +/// duplicate import. `is_batch_derived` in `agent_presence.rs` checks the +/// version its writer stamps for the same reason; this checks version 4. +pub(in crate::agent) fn valid_batch_id(value: &str) -> bool { + value + .strip_prefix("bridge-") + .and_then(|uuid| Uuid::parse_str(uuid).ok().map(|parsed| (uuid, parsed))) + .is_some_and(|(spelled, parsed)| { + parsed.to_string() == spelled + && parsed.get_version() == Some(uuid::Version::Random) + && parsed.get_variant() == uuid::Variant::RFC4122 + }) +} + +/// The character rule `build_import_xml` enforces on a caller's transaction +/// label. Presence reads it too: a label the writer would have refused cannot +/// have produced a narration marker, so hashing one would derive an identity +/// no book can hold. One rule, so read time and write time cannot drift. +pub(in crate::agent) fn valid_txn_id(value: &str) -> bool { !value.is_empty() && value.len() <= 64 && value @@ -2038,13 +2088,9 @@ fn verify_batch(line: &ImportLedgerLine, observed: &ImportReadSource) -> Result< let observed_tags = observed .iter() .map(|voucher| { - voucher - .narration - .as_deref()? - .split_once("[BRIDGE:")? - .1 - .split_once(']') - .map(|(tag, _)| tag) + narration_markers(voucher.narration.as_deref()?) + .next() + .flatten() }) .collect::>(); let mut tagged = BTreeMap::<&str, VerificationCandidates>::new(); diff --git a/src-tauri/src/agent_import_identity.rs b/src-tauri/src/agent_import_identity.rs index 51e6e6a2..17c0e34f 100644 --- a/src-tauri/src/agent_import_identity.rs +++ b/src-tauri/src/agent_import_identity.rs @@ -13,7 +13,7 @@ pub(super) enum ImportIdentityScheme { /// The random, persisted batch UUID separates independent file generations. /// Domain separation and tuple encoding keep identities deterministic without /// treating a caller's commonly reused transaction label as a Tally upsert key. -pub(super) fn import_identity(batch_id: &str, txn_id: &str) -> Uuid { +pub(in crate::agent) fn import_identity(batch_id: &str, txn_id: &str) -> Uuid { let input = serde_json::to_vec(&("bridge.mcp.import.v1", batch_id, txn_id)) .expect("string tuple serializes"); let digest = Sha256::digest(input); diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs index f6d164e2..e5090397 100644 --- a/src-tauri/src/agent_presence.rs +++ b/src-tauri/src/agent_presence.rs @@ -10,9 +10,10 @@ use std::collections::BTreeSet; use bridge_tally_core::book_presence::{ self, BookWindow, NumberingDeclaration, NumberingMethod, ObservedEntry, ObservedVoucher, - PresenceError, PresenceReport, PresenceRequest, ProposedVoucher, ProposedVoucherInput, - RawObservationBudget, RemoteIdEvidence, WindowRead, + ObservedWindow, PresenceError, PresenceReport, PresenceRequest, ProposedVoucher, + ProposedVoucherInput, RawObservationBudget, WindowRead, }; +use bridge_tally_core::book_presence::{ColumnEvidence, ObservedMarker}; use bridge_tally_core::master_binding::{MasterCatalog, MasterClass, SourceEntity}; /// Most vouchers one presence request may propose. The window read is @@ -263,25 +264,64 @@ fn book_window( ) -> Result { let mut budget = RawObservationBudget::default(); let mut entries = Vec::with_capacity(rows.len().min(book_presence::MAX_WINDOW_VOUCHERS)); + let mut ambiguous = Vec::with_capacity(rows.len().min(book_presence::MAX_WINDOW_VOUCHERS)); for row in rows { let raw = row["amounts"] .as_array() .map(Vec::as_slice) .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(), - present_text(&row["voucher_number"]), - None, - present_text(&row["party"]), - raw.iter().map(|entry| { - ( - entry["ledger"].as_str().unwrap_or_default(), - entry["amount"].as_str().unwrap_or_default(), - ) - }), - )?; + let narration = row["narration"].as_str(); + match marker_kind(narration) { + MarkerKind::Absent => 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(), + present_text(&row["voucher_number"]), + None, + present_text(&row["party"]), + None, + std::iter::empty(), + raw.iter().map(|entry| { + ( + entry["ledger"].as_str().unwrap_or_default(), + entry["amount"].as_str().unwrap_or_default(), + ) + }), + )?, + MarkerKind::Identifying(marker) => 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(), + present_text(&row["voucher_number"]), + None, + present_text(&row["party"]), + Some(marker), + std::iter::empty(), + raw.iter().map(|entry| { + ( + entry["ledger"].as_str().unwrap_or_default(), + entry["amount"].as_str().unwrap_or_default(), + ) + }), + )?, + MarkerKind::Unidentified => 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(), + present_text(&row["voucher_number"]), + None, + present_text(&row["party"]), + None, + ambiguous_markers_iter(narration), + raw.iter().map(|entry| { + ( + entry["ledger"].as_str().unwrap_or_default(), + entry["amount"].as_str().unwrap_or_default(), + ) + }), + )?, + } + ambiguous.push(ambiguous_markers(narration)); entries.push( row["amounts"] .as_array() @@ -295,23 +335,113 @@ fn book_window( .collect::>(), ); } - 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: present_text(&row["voucher_number"]), - remote_id: None, - party: present_text(&row["party"]), - 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) + let observations = + rows.iter() + .zip(&entries) + .zip(&ambiguous) + .map(|((row, entries), ambiguous)| 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: present_text(&row["voucher_number"]), + remote_id: None, + party: present_text(&row["party"]), + marker: match observed_marker(row["narration"].as_str()) { + ObservedMarker::Unidentified(_) => ObservedMarker::Unidentified(ambiguous), + settled => settled, + }, + entries, + cancelled: row["cancelled"].as_bool().unwrap_or_default(), + optional: row["optional"].as_bool().unwrap_or_default(), + }); + BookWindow::from_observations(ObservedWindow { + from, + to, + read, + remote_id_evidence: ColumnEvidence::NotRead, + narration_evidence: ColumnEvidence::Observed, + vouchers: observations, + }) +} + +/// Applies the `[BRIDGE:...]` convention to one observed narration. +/// +/// The convention belongs to the writer, so it is read here and the core crate +/// receives an opaque string. Two conditions must hold before a marker names +/// an import, and they fail closed for different reasons (ADR 0018 §3): +/// +/// - **Exactly one occurrence.** Two mean the voucher claims two imports, +/// which is the middle case this contract never resolves; `verify_import` +/// already treats it as an error rather than taking the first. +/// - **The canonical form this writer produces.** A marker is +/// `import_identity`'s UUID over a random batch id. An older scheme wrote the +/// caller's transaction label instead, and those are, in this module's own +/// words, commonly reused -- matching one would pair a proposal with an +/// unrelated voucher from an unrelated batch and drop an invoice silently. +fn observed_marker(narration: Option<&str>) -> ObservedMarker<'_> { + match marker_kind(narration) { + MarkerKind::Absent => ObservedMarker::Absent, + MarkerKind::Identifying(identity) => ObservedMarker::Identifying(identity), + MarkerKind::Unidentified => ObservedMarker::Unidentified(&[]), + } +} + +#[derive(Clone, Copy)] +enum MarkerKind<'a> { + Absent, + Identifying(&'a str), + Unidentified, +} + +fn marker_kind(narration: Option<&str>) -> MarkerKind<'_> { + let Some(narration) = narration else { + return MarkerKind::Absent; + }; + let mut found = agent_import::narration_markers(narration); + match found.next() { + None => MarkerKind::Absent, + Some(Some(identity)) if is_batch_derived(identity) && found.next().is_none() => { + MarkerKind::Identifying(identity) + } + _ => MarkerKind::Unidentified, + } +} + +/// The well-formed occurrences in a narration that could not identify one +/// import. They cannot decide, and they must not be thrown away: a proposal +/// whose own marker is among them is asking about this exact voucher. +fn ambiguous_markers(narration: Option<&str>) -> Vec<&str> { + ambiguous_markers_iter(narration).collect() +} + +fn ambiguous_markers_iter(narration: Option<&str>) -> impl Iterator { + narration.into_iter().flat_map(|narration| { + agent_import::narration_markers(narration) + .flatten() + .filter(|identity| is_batch_derived(identity)) + }) +} + +/// Whether a marker has the exact shape `import_identity` writes. +/// +/// Parsing alone is not enough, and neither is the canonical spelling. The +/// writer builds its identity with `Uuid::Builder::from_custom_bytes`, which +/// stamps **version 8** and the RFC 4122 variant into the bytes it is given, +/// so a value that carries any other version cannot have come from it. +/// +/// That matters because a caller's transaction label may legally be +/// UUID-shaped: `valid_txn_id` admits hex and hyphens, so a legacy-scheme +/// write could put a canonical v4 UUID in a narration and this would have +/// called it batch-derived. Checking the version rejects that whole class +/// rather than the fraction of it that happens to look wrong. +fn is_batch_derived(identity: &str) -> bool { + uuid::Uuid::parse_str(identity).is_ok_and(|parsed| { + parsed.to_string() == identity + && parsed.get_version() == Some(uuid::Version::Custom) + && parsed.get_variant() == uuid::Variant::RFC4122 + }) } /// Reads an optional text field the way the JSON above actually spells absence. @@ -368,12 +498,44 @@ fn parse_proposals( voucher_number: Option, party: Option, entries: Vec<(String, String)>, + marker: Option, } 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 raw_date = voucher["date"].as_str().ok_or_else(invalid)?; let raw_type = voucher["voucher_type"].as_str().ok_or_else(invalid)?; + // Both or neither. Supplying one alone is a caller error, and silently + // ignoring it would skip the strongest key this proposal has. + let marker = match ( + voucher["batch_id"].as_str(), + voucher["bridge_txn_id"].as_str(), + ) { + (Some(batch_id), Some(txn_id)) => { + // The published pattern is documentation: the shared validator + // enforces `minLength`, `maxLength` and the one `\S` special + // case, and evaluates no other regular expression. So the + // character rule is enforced here, with the writer's own + // function -- a label `build_import_xml` would have refused + // cannot have produced a marker, and hashing it anyway derives + // an identity no book can hold and calls the result `absent`. + if !agent_import::valid_txn_id(txn_id) { + return Err("argument_invalid:bridge_txn_id".to_string()); + } + // Same rule, other half of the pair. A mistyped batch id is + // not a harmless miss: under automatic numbering, with nothing + // resembling the proposal, the derived-but-impossible marker + // matches nothing and the window reports `absent` -- which + // invites the duplicate import this contract exists to stop. + // Bad input should say it is bad input. + if !agent_import::valid_batch_id(batch_id) { + return Err("argument_invalid:batch_id".to_string()); + } + Some(agent_import::import_identity(batch_id, txn_id).to_string()) + } + (None, None) => None, + _ => return Err("presence_import_identity_incomplete".to_string()), + }; 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 @@ -384,6 +546,7 @@ fn parse_proposals( raw_date, raw_type, voucher["voucher_number"].as_str(), + marker.as_deref(), None, voucher["party"].as_str(), rows.iter().map(|entry| { @@ -413,6 +576,7 @@ fn parse_proposals( voucher_number: voucher["voucher_number"].as_str().map(str::to_string), party: voucher["party"].as_str().map(str::to_string), entries, + marker, }); } // Materialize entry descriptors so their borrowed slices outlive the @@ -436,6 +600,7 @@ fn parse_proposals( voucher_type: &voucher.voucher_type, voucher_number: voucher.voucher_number.as_deref(), remote_id: None, + narration_marker: voucher.marker.as_deref(), party: voucher.party.as_deref(), entries: &descriptors[position], }); diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index ef0cc4ce..3e46e7f3 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -38,6 +38,88 @@ fn proposal(number: &str, party: &str, total: &str) -> Value { }) } +#[test] +fn proposal_marker_bytes_are_admitted_before_entry_conversion() { + let batch = "bridge-2b1c9f4e-9d3a-4f71-8c2e-5a6b7c8d9e01"; + let marker = agent_import::import_identity(batch, "txn-001").to_string(); + assert_eq!(marker.len(), 36); + let party = "P".repeat( + book_presence::MAX_TEXT_CHARS + - "20260901".len() + - "Journal".len() + - "1".len() + - marker.len(), + ); + let mut vouchers = (0..256) + .map(|_| { + json!({ + "date": "20260901", + "voucher_type": "Journal", + "voucher_number": "1", + "party": party, + "batch_id": batch, + "bridge_txn_id": "txn-001", + "entries": [{"ledger": "L", "amount": "1"}], + }) + }) + .collect::>(); + assert_eq!( + parse_proposals(&json!({"vouchers": vouchers})) + .expect("exact marker metadata limit") + .as_slice() + .len(), + 256 + ); + // The public adapter owns this marker. One valid extra party byte must be + // rejected by raw admission before an invalid amount can be parsed. + vouchers[255]["party"] = json!(format!("{party}P")); + vouchers[255]["entries"][0]["amount"] = json!("not-an-amount"); + assert_eq!( + parse_proposals(&json!({"vouchers": vouchers})), + Err("presence_proposal_raw_bytes_too_large".to_string()) + ); +} + +#[test] +fn book_window_admits_marker_metadata_before_entry_descriptors() { + let marker = + agent_import::import_identity("bridge-2b1c9f4e-9d3a-4f71-8c2e-5a6b7c8d9e01", "txn-001") + .to_string(); + let guid_prefix = "00000000-0000-4000-8000-000000000000"; + let number = "N".repeat( + book_presence::MAX_TEXT_CHARS + - format!("{guid_prefix}-00000000").len() + - "20260901".len() + - "Journal".len() + - marker.len(), + ); + let mut rows = (0..256) + .map(|position| { + json!({ + "guid": format!("{guid_prefix}-{position:08}"), + "date": "20260901", + "voucher_type": "Journal", + "voucher_number": number, + "narration": narration_with(&marker), + "amounts": [{"ledger": "L", "amount": "1"}], + }) + }) + .collect::>(); + assert_eq!( + book_window("20260901", "20260930", WindowRead::Complete, &rows) + .expect("exact marker metadata limit") + .vouchers() + .len(), + 256 + ); + rows[255]["voucher_number"] = json!(format!("{number}N")); + rows[255]["amounts"][0]["amount"] = json!("not-an-amount"); + assert_eq!( + book_window("20260901", "20260930", WindowRead::Complete, &rows), + Err(PresenceError::WindowRawEntryBytesTooLarge) + ); +} + fn args(vouchers: Value, numbering: &str) -> Value { json!({ "company_guid": CAPTURED_GUID, @@ -241,17 +323,19 @@ fn a_caller_limited_presence_page_includes_its_resume_cursor() { voucher_type: "Journal", voucher_number: Some(if position == 0 { "JV-0" } else { "JV-1" }), remote_id: None, + narration_marker: None, party: None, entries: &entries, }); let proposals = ProposedVoucher::from_inputs(proposal_inputs).expect("proposals"); - let window = BookWindow::from_observations( - "20260901", - "20260930", - WindowRead::Complete, - RemoteIdEvidence::NotRead, - std::iter::empty(), - ) + let window = BookWindow::from_observations(ObservedWindow { + from: "20260901", + to: "20260930", + read: WindowRead::Complete, + remote_id_evidence: ColumnEvidence::NotRead, + narration_evidence: ColumnEvidence::NotRead, + vouchers: std::iter::empty(), + }) .expect("complete empty window"); let catalogue = vec!["Cash".to_string(), "Sales".to_string()]; let catalog = MasterCatalog::new(MasterClass::Ledger, &catalogue).expect("catalog"); @@ -632,6 +716,24 @@ fn presence_plans() -> Vec { plans(steps) } +/// `presence_plans` with one marker written into JV-1's narration, the way an +/// earlier Bridge import would have left it. +fn marker_presence_plans(marker: &str) -> Vec { + let catalogue = catalogue_xml(); + let window = window_xml().replace( + "JV-1", + &format!( + "JV-1{}", + narration_with(marker) + ), + ); + let mut steps = vec![Step::Company, Step::Status, Step::Company, Step::Status]; + steps.extend(paired_read(&catalogue)); + steps.extend(paired_read(&window)); + steps.extend(paired_read(&catalogue)); + plans(steps) +} + #[tokio::test] async fn a_nonempty_window_without_a_control_total_still_answers_but_never_issues_absent() { let simulator = SequenceSimulator::spawn(presence_plans()).expect("simulator"); @@ -695,6 +797,64 @@ async fn a_nonempty_window_without_a_control_total_still_answers_but_never_issue assert_eq!(observed.len(), 22); } +/// The same window carrying a marker Bridge wrote, under `automatic` numbering. +/// +/// This settles the reachability question ADR 0018's basis depends on. The +/// nonempty-window short-circuit used to refuse before any verdict was built, +/// so `PresenceBasis::NarrationMarker` could not be produced through this +/// adapter whatever the narration held -- the basis was contract-complete and +/// operationally dead. Numbering is `automatic` here deliberately: Tally +/// discards a supplied number under it, so the number can decide nothing and +/// the marker is the only basis left that can produce `present`. A `present` +/// therefore proves the marker path carries it, rather than restating the +/// voucher-number path the sibling test above already covers. +#[tokio::test] +async fn a_narration_marker_decides_a_present_from_a_nonempty_partial_window() { + let marker = agent_import::import_identity(BATCH, "txn-001").to_string(); + let simulator = SequenceSimulator::spawn(marker_presence_plans(&marker)).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":"automatic"}], + "vouchers": [{ + "date": "20260901", + "voucher_type": "Journal", + "party": "Bridge Nested Debtor WR4", + "batch_id": BATCH, + "bridge_txn_id": "txn-001", + "entries": [ + {"ledger":"Bridge Nested Debtor WR4","amount":"-12.50"}, + {"ledger":"WR2 Sales","amount":"12.50"}, + ], + }], + }), + ) + .await; + assert_eq!(response["isError"], false, "{response}"); + let result = &response["structuredContent"]["result"]; + assert_eq!(result["window"]["read"], "partial"); + let items = result["items"].as_array().expect("items"); + assert_eq!(items[0]["presence"], "present", "{items:?}"); + assert_eq!(items[0]["basis"], "narration_marker", "{items:?}"); +} + /// The admission contract this tool enforces lives in `agent_catalog.rs`. /// /// This comment used to say that file was **not** in the compatibility @@ -738,7 +898,7 @@ fn every_admission_leaf_is_pinned_by_this_digest() { // 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"; + const PINNED: &str = "785b14835f3235ec009a248ac2b443316e764c584b31f2532aa1335365c5fb40"; let definitions = tool_definitions(true, false); let schema = definitions .as_array() @@ -1307,3 +1467,306 @@ async fn replay_the_twenty_invoice_engagement() { "partial" ); } + +// --------------------------------------------------------------------------- +// ADR 0018 — reading the narration marker. +// +// These are the adapter's half of the basis. The crate never parses a marker; +// everything that decides what counts as one is here, so this is where it has +// to be pinned down. +// --------------------------------------------------------------------------- + +/// A batch id has the shape `render_import_xml` generates for one. +const BATCH: &str = "bridge-2b1c9f4e-9d3a-4f71-8c2e-5a6b7c8d9e01"; + +fn narration_with(marker: &str) -> String { + format!( + "Invoice for the month {}{marker}]", + agent_import::NARRATION_MARKER_PREFIX + ) +} + +/// The one property the whole basis rests on: what the reader accepts is +/// exactly what the writer writes. If these two ever disagree, presence reports +/// every voucher Bridge imported as absent and a caller duplicates all of them. +#[test] +fn the_reader_accepts_exactly_what_the_writer_derives() { + let identity = agent_import::import_identity(BATCH, "txn-001").to_string(); + let narration = narration_with(&identity); + assert_eq!( + observed_marker(Some(&narration)), + ObservedMarker::Identifying(identity.as_str()) + ); + // And the derivation is a function of both halves, not of the label alone. + assert_ne!( + identity, + agent_import::import_identity("bridge-other", "txn-001").to_string(), + "the batch is what makes a reused caller label distinct" + ); +} + +/// The safety property of ADR 0018 §3. An older scheme wrote the caller's +/// transaction label into the narration, and those labels are reused across +/// batches; matching one would pair a proposal with an unrelated voucher from +/// an unrelated import and drop an invoice without a trace. +#[test] +fn a_legacy_caller_label_is_never_an_identity() { + for label in ["txn-001", "INV-2026-0001", "batch1_txn1"] { + assert_eq!( + observed_marker(Some(&narration_with(label))), + ObservedMarker::Unidentified(&[]), + "{label} is a caller label, not a batch-derived identity" + ); + } + // Nor is a canonical UUID of some *other* version. `valid_txn_id` admits + // hex and hyphens, so a legacy-scheme write could legally have put a v4 + // UUID in a narration; only the version the writer stamps can have come + // from the writer. + assert_eq!( + observed_marker(Some(&narration_with( + "550e8400-e29b-41d4-a716-446655440000" + ))), + ObservedMarker::Unidentified(&[]), + "a canonical v4 UUID is not something import_identity can emit" + ); + + // Nor is a UUID spelled some other way than the writer spells it. + let identity = agent_import::import_identity(BATCH, "txn-001").to_string(); + for spelling in [ + identity.replace('-', ""), + identity.to_ascii_uppercase(), + format!("urn:uuid:{identity}"), + ] { + assert_eq!( + observed_marker(Some(&narration_with(&spelling))), + ObservedMarker::Unidentified(&[]), + "only the canonical form can have come from the writer" + ); + } +} + +/// Two markers mean the voucher claims two imports, and a malformed one cannot +/// name any. Both are still Bridge writes, so neither reads as `Absent`. +#[test] +fn an_ambiguous_or_malformed_marker_is_a_bridge_write_without_a_name() { + let identity = agent_import::import_identity(BATCH, "txn-001").to_string(); + let other = agent_import::import_identity(BATCH, "txn-002").to_string(); + let prefix = agent_import::NARRATION_MARKER_PREFIX; + for narration in [ + format!("{prefix}{identity}] {prefix}{other}]"), + format!("{prefix}{identity}"), + format!("{prefix}]"), + format!("{prefix}{identity} with a space]"), + ] { + assert_eq!( + observed_marker(Some(&narration)), + ObservedMarker::Unidentified(&[]), + "narration {narration:?}" + ); + } + // A narration Bridge never touched is a different fact from one it did. + assert_eq!( + observed_marker(Some("Cheque deposited at the branch")), + ObservedMarker::Absent + ); + assert_eq!(observed_marker(None), ObservedMarker::Absent); +} + +/// Half an import identity is a caller error, not something to quietly drop: +/// ignoring it would skip the strongest key this proposal has and let an +/// `absent` stand on a comparison that never ran. +#[tokio::test] +async fn an_import_identity_must_be_supplied_whole() { + 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 (half, refused) in [ + (json!({"batch_id": BATCH}), true), + (json!({"bridge_txn_id": "txn-001"}), true), + ( + json!({"batch_id": BATCH, "bridge_txn_id": "txn-001"}), + false, + ), + (json!({}), false), + ] { + let mut voucher = json!({"date":"20260901","voucher_type":"Journal","entries":entries}); + for (key, value) in half.as_object().expect("object") { + voucher[key] = value.clone(); + } + let response = server + .call_tool_response( + "voucher_presence", + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":numbering,"vouchers":[voucher]}), + ) + .await; + let code = response.value["structuredContent"]["result"]["error"]["code"] + .as_str() + .unwrap_or_default() + .to_string(); + assert_eq!( + code == "presence_import_identity_incomplete", + refused, + "half {half} was not treated as {}", + if refused { "an error" } else { "acceptable" } + ); + } +} + +/// The admission contract grew two properties, and both have to stay bounded. +/// Widening either is how a caller reaches a marker it chose rather than one +/// the writer derived. +#[test] +fn the_import_identity_inputs_are_bounded_where_they_are_published() { + let definitions = tool_definitions(true, false); + let voucher = definitions + .as_array() + .and_then(|tools| tools.iter().find(|tool| tool["name"] == "voucher_presence")) + .expect("voucher_presence tool")["inputSchema"]["properties"]["vouchers"]["items"] + .clone(); + assert_eq!(voucher["additionalProperties"], json!(false)); + for key in ["batch_id", "bridge_txn_id"] { + assert_eq!( + voucher["properties"][key]["maxLength"], + json!(64), + "{key} is unbounded" + ); + assert_eq!(voucher["properties"][key]["minLength"], json!(1)); + } + // The transaction label's alphabet is the writer's, so a caller cannot + // smuggle a shape the derivation never produces. Declaring it is not + // enforcing it -- see the test below, which is the one that matters. + assert_eq!( + voucher["properties"]["bridge_txn_id"]["pattern"], + json!("^[A-Za-z0-9_-]+$") + ); + // Neither is required: a proposal that supplies no import identity behaves + // exactly as it did before ADR 0018. + assert_eq!( + voucher["required"], + json!(["date", "voucher_type", "entries"]) + ); + // And a marker still cannot be handed over directly. + assert!(voucher["properties"].get("narration_marker").is_none()); + assert!(voucher["properties"].get("remote_id").is_none()); +} + +/// A batch id the writer could not have generated cannot have written a +/// marker, so deriving one from it yields an identity no book holds. Left +/// unchecked that is not a harmless miss: under automatic numbering, with +/// nothing resembling the proposal, the window reports `absent` and a caller +/// imports a second copy. A mistyped argument must say it is a mistyped +/// argument. Against the live simulator, so zero bytes means the refusal came +/// before the reads. +#[tokio::test] +async fn a_batch_id_the_writer_could_not_have_made_is_refused() { + 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, + }); + for (batch, refused) in [ + (BATCH, false), + // Plausible, nonblank, within bounds, and not a shape the writer emits. + ("bridge-not-a-uuid", true), + ("2b1c9f4e-9d3a-4f71-8c2e-5a6b7c8d9e01", true), + ("bridge-2B1C9F4E-9D3A-4F71-8C2E-5A6B7C8D9E01", true), + // Canonically spelled and RFC 4122 variant, but the wrong version: + // `Uuid::new_v4()` never emits a nil or a v7 UUID, so hashing either + // would derive an identity no book holds and read as `absent`. + ("bridge-00000000-0000-0000-0000-000000000000", true), + ("bridge-017f22e2-79b0-7cc3-98c4-dc0c0c07398f", true), + ] { + let mut voucher = proposal("JV-1", "Bridge Nested Debtor WR4", "12.50"); + voucher["batch_id"] = json!(batch); + voucher["bridge_txn_id"] = json!("txn-001"); + 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":[voucher]}), + ) + .await; + let code = response.value["structuredContent"]["result"]["error"]["code"] + .as_str() + .unwrap_or_default() + .to_string(); + assert_eq!( + code == "argument_invalid:batch_id", + refused, + "batch id {batch:?} produced {code:?}" + ); + if refused { + assert_eq!( + response.value["structuredContent"]["evidence"]["bytes"], 0, + "a batch id the writer could not have made must cost no read" + ); + } + } +} + +/// The published nested transaction-label pattern is evaluated by the shared +/// schema validator before proposal parsing. Schema recursion keeps the declared +/// top-level argument code, so a bad nested label reports +/// `argument_invalid:vouchers`, rather than inventing a leaf-code contract. +/// The typed parser retains the writer's `valid_txn_id` check as a defensive +/// boundary. Against the live simulator, zero bytes proves the schema refusal +/// happened before any read. +#[tokio::test] +async fn a_transaction_label_outside_the_published_nested_schema_is_refused_before_reads() { + 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, + }); + // A space is the case the writer rejects and the declared pattern names. + for (label, refused) in [("txn 001", true), ("txn-001", false)] { + let mut voucher = proposal("JV-1", "Bridge Nested Debtor WR4", "12.50"); + voucher["batch_id"] = json!("bridge-2b1c9f4e-9d3a-4f71-8c2e-5a6b7c8d9e01"); + voucher["bridge_txn_id"] = json!(label); + 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":[voucher]}), + ) + .await; + let code = response.value["structuredContent"]["result"]["error"]["code"] + .as_str() + .unwrap_or_default() + .to_string(); + assert_eq!( + code == "argument_invalid:vouchers", + refused, + "label {label:?} produced {code:?}" + ); + if refused { + assert_eq!( + response.value["structuredContent"]["evidence"]["bytes"], 0, + "a nested-schema refusal must cost no read" + ); + } + } +} diff --git a/tools/bridge-tally-compatibility/src/lib.rs b/tools/bridge-tally-compatibility/src/lib.rs index 3af66c4d..785ed762 100644 --- a/tools/bridge-tally-compatibility/src/lib.rs +++ b/tools/bridge-tally-compatibility/src/lib.rs @@ -31,15 +31,18 @@ pub const RESERVED_SURFACE_FILES: usize = 15; /// and manifest) but makes further unreviewed additions an explicit /// compatibility-surface decision. /// -/// **Raised four times, the first three by branches that did not see each +/// **Raised five times, the first three by branches that did not see each /// other.** 210 to 211 on master for `src-tauri/src/agent_ledgers.rs`, 211 to /// 212 for `src-tauri/crates/bridge-tally-core/src/master_binding.rs`, 212 to -/// 215 for the voucher-presence engine plus its adapter and admission-contract -/// assertion, and 216 to 217 for -/// `.github/workflows/dependency-security-scheduled.yml`. Each reason stands; a -/// merge that keeps a raise but loses its pin would pass the gate with behavior -/// silently outside the evidence boundary, which is the failure this constant -/// exists to make loud. +/// 216 in a single commit for the voucher-presence engine, its adapter, its +/// admission-contract assertion, and `agent_catalog.rs` -- the last of those +/// taking the slot a paragraph below had already reserved for it by name, which +/// is why the four pins arrive as one raise and not two -- 216 to 217 for +/// `.github/workflows/dependency-security-scheduled.yml`, and 217 to 218 for +/// `src-tauri/src/agent_import_identity.rs`. Each reason stands; a merge that +/// keeps a raise but loses its pin would pass the gate with behavior silently +/// outside the evidence boundary, which is the failure this constant exists to +/// make loud. /// /// `master_binding.rs` decides `validate_masters` results and, through them, /// import admission. Left unpinned, an edit confined to the matcher would leave @@ -57,7 +60,15 @@ pub const RESERVED_SURFACE_FILES: usize = 15; /// surface digest unchanged. Every other raise here bound a file that decides /// what Bridge admits; this one binds a file that decides what Bridge is /// allowed to do to its own repository while nobody is watching. -pub const MAX_SURFACE_FILES: usize = 217; +/// +/// The raise to 218 binds `agent_import_identity.rs`. The marker derivation it +/// holds is shared by the import writer and the presence reader: the writer +/// stamps a marker into a voucher's narration, and the reader identifies that +/// voucher by it. Left unpinned, an edit to that one derivation would change +/// both halves at once and leave the surface digest unchanged, so a receipt +/// would attest an identity rule the evidence never covered. It is one file for +/// one named reason — not headroom. +pub const MAX_SURFACE_FILES: usize = 218; pub const MAX_OPERATIONS: usize = 16; pub const MAX_CLAIMS: usize = 128; pub const MAX_KEYS: usize = 32;