diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md new file mode 100644 index 00000000..b5948781 --- /dev/null +++ b/docs/adr/0017-voucher-presence-authority.md @@ -0,0 +1,672 @@ +# ADR 0017: Voucher presence is decided by identity, never by resemblance, and the doubt is never resolved + +## Status + +Accepted for the shared presence contract in `bridge-tally-core`, consumed by +the agent/MCP layer. It decides, for a set of proposed vouchers, which are +already in one company's book **within one observed window**. It selects +nothing, writes nothing, and dispatches nothing. Master creation, voucher +generation, posting, deletion, and any scored or model-assisted matching remain +rejected without separate evidence. + +## Context + +**Tally dedupes on one key and no other.** Re-sending an identical voucher +payload with the same `VOUCHERNUMBER` creates a second voucher — verified, and +recorded in [`TALLY_PROTOCOL_REFERENCE.md` +§9.3](../tally/TALLY_PROTOCOL_REFERENCE.md). A *client-supplied* `REMOTEID` is +the exception and the only one: +[`IMPLEMENTATION_GUIDE.md` +§3.3a](../tally/IMPLEMENTATION_GUIDE.md#33a-remoteid-is-the-idempotency-key--supersedes-34s-conclusion) +verified that re-importing the same payload under the same client `REMOTEID` +**upserts** — `CREATED=0, ALTERED=1`, one voucher, not two — and §9.8's scope +clarification records the same result on the licensed Journal path. + +Read §9.3's heading alone and you get "no idempotency", flat; that heading is +narrower than it reads and does not point at the exception. **Neither fact +rescues this contract, because both are about a voucher the *client* keyed.** +A voucher an operator typed into Tally by hand carries no client `REMOTEID` to +dedupe against, and duplicated invoices inside a filed GST period are a return +problem, not a cosmetic one. + +So before any generated batch can be imported, one question has to be answered +and Bridge cannot answer it: + +- One engagement's month of sales: twenty invoices in the source report, **fifteen + already keyed in by hand.** That was discovered only because the operator + happened to send a Day Book screenshot. Without it the run would have posted + twenty and duplicated fifteen. +- Another engagement's month of sales: **forty-nine vouchers generated, validated, + arithmetic-checked, and un-importable at the end of the day**, waiting for a + Day Book to arrive by hand the next morning. + +Both books were **hand-keyed**, so no voucher in either carried a `REMOTEID` +Bridge had written. Both engagements were blocked on the same day. + +The question also has diagnostic value on its own. One of those books already +held **twenty-five invoices in a single month sharing voucher numbers** — +visible in Tally's own `Duplicate Voucher No.` exceptions before any import ran. +Indexing a book window by voucher number finds that for free. + +Bridge already performs the read this needs: `vouchers` returns a literal-window +voucher list with date, voucher type, voucher number, party ledger, entry +ledgers and amounts. What is missing is the comparison and, more importantly, +the contract that says what a comparison is allowed to conclude. + +### Why the obvious keys are each insufficient + +| Candidate key | Where it holds | Where it fails | +| --- | --- | --- | +| `REMOTEID` | A voucher whose Tally-assigned value the caller has already observed. | The **attribute** does not carry the client's key back: §3.3a verified Tally overwrites it with its own value. It does not follow that the key is unreadable — §9.8's batch-identity run recorded the `REMOTEID` and the **narration marker** sharing one batch-derived UUID, so it survives in the field Tally does not own. Whether a voucher keyed by hand in the Tally UI carries a Tally-assigned value is **untested in either direction**. | +| `VOUCHERNUMBER` | Voucher types numbered **Manual**. One book preserved a long alphanumeric invoice series verbatim, another a plain three-digit bill number. | Under **Automatic** numbering Tally *discards* the supplied number (§9.8), so a number-based key is silently ineffective. And a book that does not set `PREVENTDUPLICATES` can hold the same number twice — one did, twenty-five times. | +| date + party + amount | Needs neither of the above. | Collides. In one month of real data `141,600`, `177,000` and `16,992` each recurred across *unrelated* parties. | + +No single key decides. A contract that pretends one does will be wrong in the +field, quietly. + +## Decision + +Presence is a **pure, deterministic function in `bridge-tally-core`** over (one +observed window of a company's book, the ledger catalog observed for that same +company, the vouchers a source document proposes). It performs no I/O, holds no +transport handle, calls no model, and depends on nothing above +`bridge-tally-primitives` and `master_binding`. + +### 1. Party matching is not reinvented — it is `master_binding` + +"Is this the same customer" is the question ADR 0016 already answers, and there +must not be a second answer to it. A proposal's party name is bound to the +observed ledger catalog through `master_binding::bind`, and the result is +consumed as-is: + +- **`Bound`** — the bound catalog name is the one name party rules compare + against. +- **`Ambiguous`** — *every* candidate name is compared against. Using the whole + candidate set can only produce more resemblance, never less, which is the + safe direction here; picking one of them would be the auto-resolution ADR + 0016 forbids. +- **`Unmatched`** — no name is compared. A party with no ledger and nothing + resembling one cannot be carrying a posted voucher in this book, so + party-independent rules are all that remain and `Absent` stays available. + +Three further conditions withhold `Absent` before any of that, and they share +one shape: **a field the rules would otherwise have used was not supplied.** +Note what that does *not* cover — a voucher type numbered `Automatic` has no +decisive key at all, so a proposal under it withheld nothing and its `Absent` +stands on the resemblance rules having found nothing, which is exactly what +`Absent` claims. The withholding is for evidence that was available and is +missing, never for evidence that does not exist. A +proposal that names no party (`PartyNotSupplied`) leaves an absence resting on +date and amount, the pair this contract says collides. A proposal under a +`Manual` declaration that supplies no voucher number +(`ManualNumberNotSupplied`) has withheld the one key that could decide. And a +proposal carrying a `REMOTEID` the window never read +(`RemoteIdEvidenceUnavailable`) had its strongest key skipped. Missing party +and number cases do not block `Present` when another identity settles it. +Unread `REMOTEID` does: a manual-number match becomes `PossiblyPresent`, because +the two identity channels could contradict. In every case the *absence* claim +is withheld; supplying the missing evidence is what makes it available. + +Two binding outcomes withhold `Absent` outright: `NoDiscriminatingCandidate` +(a name family that is deliberately not listed) and a truncated candidate list. +In both, names that might have matched were never compared, and reporting +"absent" off an incomplete comparison is the failure this ADR exists to prevent. + +That withholding is not a precaution reasoned from the contract alone. Measured +over 470 ledger names from sixteen loaded synthetic companies and 2,257 +mutation cases: where binding lists candidates the right master is present in +403 of 403 rows, and where a source name reaches a family it cannot distinguish +an alphabetically capped slice of that family **omitted the right master about +a third of the time** — which is why the family is counted and not listed. On a +book with systematic party naming `NoDiscriminatingCandidate` is expected to be +common, and a third of the `Absent` verdicts it would otherwise license would +have been wrong. + +The trap this guards is not hypothetical, and it is not confined to this +contract. A sibling surface consuming the same state rendered an +undiscriminable family as "0 possible ledgers are listed first" — a count of +nothing, presented to an operator as a result. An empty candidate list means +"nothing survived to be shown", never "nothing exists"; every consumer of +`master_binding` has to decide which of those it is reporting, and the two +readings are one word apart in the output. + +One further withholding has a different shape from every condition above: it is +about the window rather than the proposal or its binding, and §2 states it. A +window not proven to have been read whole withholds `Absent` from every +proposal in it, however complete that proposal's own evidence was. + +### 2. A window is a *claim about a window*, and only `Absent` needs it complete + +`BookWindow::observed` is a boundary parse. It refuses, rather than degrades, +on: + +- **a `REMOTEID` column that was never read.** A window declares + `RemoteIdEvidence::Observed` or `NotRead`, because "no voucher carried one" + and "the profile never fetched it" are different facts and only the first is + evidence. Where a proposal carries a `REMOTEID` and the window is `NotRead`, + that proposal's strongest key was never compared, so it **cannot be + `Absent`** — it becomes `RemoteIdEvidenceUnavailable`. A report-level marker + would not have done: a status field does not neutralise the per-voucher + verdict printed beside it, which is the defect this contract cites elsewhere + and had reproduced here. +- a window that does not **cover** every proposed date — `WindowDoesNotCover`. + A voucher outside the window is invisible, so a verdict over it would be + fiction; +- a voucher dated outside the window's own range, a duplicate voucher key, an + invalid range, or a window past its bound. + +**Completeness of the read is not one of those refusals. It is a gate on +`Absent` alone, and it sits where `Absent` is produced.** A window declares the +`WindowRead` its source read reported — `Complete` or `Partial` — and retains +it; both construct, and `BookWindow::read()` carries the answer forward to +`decide`. The asymmetry is the point. `Present` and `PossiblyPresent` are +claims about rows that *were* read: an identity match names a row in hand, and +a resemblance names rows in hand, and neither is made stronger or weaker by +rows nobody saw. `Absent` is the only verdict that claims something about the +rows nobody saw — "not anywhere in this window" — so it is the only one that +needs the window read whole. A window whose emptiness was only partially +corroborated is not "no match found"; it is "no match found in the part that +was read", and conflating the two remains the single most dangerous confusion +available here. + +So `decide` checks `read()` at the one place `PresenceStatus::Absent` is +produced, and a `Partial` window degrades that verdict to +`PossiblyPresent(WindowNotProvenComplete)` rather than issuing it. That reason +has two spellings and they are not interchangeable: `safe_reason_code()` +returns `presence_window_not_proven_complete`, while the tool's per-item +`reason` field carries the serde spelling `window_not_proven_complete`. + +The check is **last** among the withholdings, after +`RemoteIdEvidenceUnavailable`, `ManualNumberNotSupplied` and the party +outcomes, so a proposal that already +withheld evidence of its own is reported under that more specific reason; +`WindowNotProvenComplete` is only ever reported when nothing else was missing +and the window itself is the sole reason the absence cannot be claimed. A +window that could not be read at all still never reaches the constructor: the +read fails, and the tool fails with it. What the gate does **not** cover is +named in the Consequences — a response Tally answers short without saying so. + +An earlier revision placed this gate at construction instead, refusing a +`Partial` read as `PresenceError::WindowIncomplete`. That was right about the +hazard and wrong about its blast radius: a construction-time refusal withholds +`Present` and `PossiblyPresent` too, and since the only window this adapter can +currently build over a nonempty range *is* `Partial` (see the Consequences), +the tool emitted no verdicts at all over such a range where it was entitled to +emit `present` and `possibly_present`. The rule did not change when the gate +moved — only `Absent` ever needed completeness, and only `Absent` is now +withheld for its absence. + +Every verdict is therefore explicitly scoped to the window the report carries. +`Absent` means *absent from this window* — it never means "absent from the +book". A voucher keyed in September against an August window is not visible, +and widening the window is the caller's decision, made in the open. + +Raw book observations are admitted before decimal parsing, cloning, or folding: +**100,000** total entries and **4 MiB** of entry ledger-and-amount bytes are +the entry limits. Retained voucher metadata is separately bounded to **4 MiB** +before cloning. Raw proposal batches likewise admit at most **5,000** proposals, +**100,000** entries, **4 MiB** of entry bytes and **4 MiB** of retained metadata. +The opaque admitted batch is required by the core request, so separately +converted vectors cannot be concatenated around admission. Source positions +must be unique across that batch. + +Admission is also bounded before comparison: a request above **1,000,000** +proposal/window pairs, or above **5,000,000** aggregate indexed resemblance +work units, is refused as `ComparisonWorkTooLarge`. The second limit counts +posting-list walks and party-key checks, so it still applies when the pair count +is below one million but one party resolves to many candidate keys. + +### 3. The numbering method is declared, and its absence is an error + +The decisive power of a voucher number depends entirely on the voucher type's +numbering method (§9.8), and Bridge has **no qualified voucher-type read**: the +protocol reference records that a numbering preflight "would require a +separately observed voucher-type read contract". + +So the method is supplied by the caller as an explicit declaration per voucher +type, with three values — `Manual`, `Automatic`, `Unknown` — and a voucher type +named by a proposal but absent from the declaration is +`NumberingMethodUndeclared`, a typed error. A silently defaulted declaration +would silently decide whether the strongest available key is usable at all. +`Unknown` remains fully legal and is the honest answer most of the time; it +simply demotes the number from identity to resemblance. + +This makes a real protocol fact operationally visible: on an automatically +numbered voucher type, Bridge will decide nothing, and will say so, rather than +matching on a number Tally threw away. + +### 4. Three statuses. The middle one is never resolved + +Per proposed voucher, exactly one of: + +| status | meaning | what it authorises | +| --- | --- | --- | +| `Present { book_key, basis, differences }` | An identity key matched, uniquely on both sides | excluding this voucher from the import | +| `PossiblyPresent { reason, candidates, .. }` | Something resembles it, or something prevented a decision | **nothing** | +| `Absent` | No rule produced any candidate, in a window proven to cover it **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 +voucher's own ordering. **No candidate is marked best, likely or preferred, and +no score is emitted anywhere.** + +This is not a stylistic echo of ADR 0016; it is the same defect being refused +twice. Bridge has already shipped a bug in exactly this family — a `near_miss` +status that carried a guessed `exact_live_spelling` in the same object, so +whichever field a consumer read first decided the outcome. A status does not +disarm a value printed beside it. Here the guard is structural: a +`PossiblyPresent` has no field that names a match, and `Present` is the only +variant that can carry one. + +### 5. Only identity produces `Present` + +Two bases, and nothing else: + +- **`RemoteId`** — the proposal and exactly one book voucher carry the same + `REMOTEID`, and no other proposal carries it. Note carefully what a caller + may put there: **not** the client key it wrote on a previous import, which + §3.3a verified Tally overwrites in the attribute, but a Tally-assigned value + it has previously read back. +- **`ManualVoucherNumber`** — the voucher type is declared `Manual`, and the + (voucher type, normalized number) pair selects **exactly one book voucher and + exactly one proposal**. Uniqueness on both sides is ADR 0016's rule 2, and it + is what makes the twenty-five-duplicates book safe: those numbers select more + than one voucher, so they decide nothing and surface as an ambiguity instead. + +Uniqueness within a basis is not enough, and two further rules close what it +leaves open: + +- **One book voucher satisfies at most one proposal, across bases.** Each basis + enforced its own uniqueness while nothing stopped two proposals reaching the + *same* voucher by *different* bases — one by `REMOTEID`, another by a manual + number. A consumer would then exclude two source vouchers against one book + row and silently drop an invoice, which is the failure this contract exists + to prevent. Every claimant of a contested voucher is demoted to + `BookVoucherClaimedTwice`; choosing between them would be auto-resolution. +- **Two identity signals that disagree are reported, not ranked.** Both + lookups are resolved *before* either settles, so a `REMOTEID` selecting one + voucher while the number selects another is `IdentityConflict` — as is a + number matching uniquely while the two sides carry different `REMOTEID`s, or + while the proposal supplies one and the book voucher has none. + Settling on whichever basis happened to be evaluated first would rank them, + which is the move ADR 0016 refuses when an identifier contradicts an exact + name. +- **Evidence that was never gathered cannot settle a `Present` either.** The + rule that withholds `Absent` when a key was not compared applies with more + force to `Present`, because `Present` carries the higher bar and its error is + the silent one. So where a proposal supplies a `REMOTEID` and the window is + `RemoteIdEvidence::NotRead`, a unique number match returns + `RemoteIdEvidenceUnavailable` rather than `Present`: the number is decisive + on its own terms, but the evidence that could contradict it was skipped. A + proposal carrying no `REMOTEID` skipped nothing and still settles. An earlier + revision of this ADR allowed that `Present`, reasoning that withholding it + would make the tool less useful — which is the posture in §7 argued + backwards, and review caught it. + +Number comparison uses a **narrower** key than master binding, and the +difference is the point. The master key folds case and unifies dash and quote +variants because §3.3b measured Tally doing exactly that to master *names*; +nothing has measured it for voucher numbers. Borrowing the conclusion without +the measurement is how an assumption acquires a citation, and this one fails in +the silent direction — folding produces *more* matches, a wrong number match is +a `Present`, and a `Present` tells a caller the invoice is already filed. Two +distinct invoices numbered `aa-0118` and `AA-0118` would each have suppressed +the other. + +So a number is compared with **outer whitespace trimming only**; its Unicode +form is preserved. Outer +padding is a transport artefact; internal whitespace, case and punctuation are +**content** until voucher-number evidence measures an equivalence. Treating +them so fails toward the noisy direction — a non-match withholds a decisive +identity result rather than treating two distinct invoices as the same one. + +Voucher *types* preserve the source spelling exactly. No case, whitespace, or +separator folding is qualified for voucher types; the type must match the +declared numbering spelling exactly. The master-name comparison key is for +ledger names only, and must not be reused for voucher types. + +A manual number decides only +**within an observed voucher type** — numbers are a per-type series, so a match +across types is a coincidence, not a series position. If a proposal's voucher +type is **not observed anywhere in the window**, type discriminates nothing, so +number matching widens to every observed type *and is demoted to a +resemblance*: it can surface candidates and can never produce `Present`. A book +carrying both `Part Sale` and `Parts Sale` is exactly why narrowing on an +unobserved type name would manufacture absence, and exactly why widening must +not be allowed to decide. + +A book voucher that is **cancelled or optional** never yields `Present`. It has +no accounting effect but does occupy its number, so a number that lands on one +is reported as `MatchedVoucherNotPosted` for a human. A struck-through and +re-issued bill is a real case, met four times in one month of one book. + +Date, amount and party are **never** a basis for `Present`. They are the keys +that measurably collide. + +### 6. `Present` reports what disagrees, and that is half the value + +A `Present` verdict compares the proposal against the book voucher it matched +and lists every difference in date, amount, or bound party. The match is on +identity, so a difference is not evidence against the match — it is a finding +about the book. + +This is not speculative: in one engagement an invoice was posted **₹36.13 +short** because one 9% GST head was dropped when it was keyed by hand, and its +voucher number still matched perfectly. Under this contract that invoice comes +back `Present` with an amount difference — precisely the report the client +needed and nobody had asked for. + +**Say plainly what a caller may do about it, because the obvious move is a +trap.** A difference is a finding for a person, not a work item for code. On +the observed instance §9.7 verified that voucher `Alter` returns +`CREATED=1, ALTERED=0` and **makes a duplicate while leaving the target +untouched** — tested against four different keys, all four duplicating — and +that `Cancel` behaves the same way (§9.6). Only `Delete` works, keyed by +`REMOTEID`. So a caller that reads "amount differs" and reaches for an `Alter` +to correct it would create the very duplicate this whole contract exists to +prevent, and Tally's counters would report success. + +The correction that does exist is re-import under the same client `REMOTEID` +(§3.3a), and it reaches **only vouchers Bridge itself wrote**. For a +hand-keyed voucher — the case that produced the ₹36.13 finding, and the case +this contract is for — Bridge holds no client key and the `vouchers` profile +does not even fetch the Tally-assigned one, so there is **no programmatic +correction path at all**. The operator fixes it in Tally. A report that names a +disagreement it cannot act on must say so, or the next person writes the +`Alter`. (§9.7's Alter and Cancel results carry their own "unverified whether +this is SKU-specific" caveat; that widens the uncertainty, it does not narrow +the advice.) + +### 7. The error posture, stated + +The two errors are not symmetric, and the asymmetry is **detectability**, not +severity: + +- A false `Present` silently drops an invoice. Nothing records it. It is not in + Tally, not in the return, not in Bridge, and not in any exceptions report. + There is no artifact to find later. +- A false `Absent` on a voucher **Bridge previously imported** creates nothing + at all *when the re-sent payload is byte-identical* — that is the case §3.3a + measured, and its own untested list names "when the payload differs from the + original". So the duplicate risk is confined to vouchers an operator keyed by + hand **plus** any re-send whose content has changed: smaller than + "everything", and larger than "hand-keyed only". +- A false `Absent` on a hand-keyed voucher does create a duplicate, and that + duplicate is **visible and correctable**: Tally's own `Duplicate Voucher No.` + exceptions report surfaces it, and re-importing under the same client + `REMOTEID` overwrites the earlier row (§3.3a's correction path) — the **least + unverified** correction available rather than a confirmed one, since §9.7's + operation matrix and the Delete row it rests on were measured on an Edit Log + 7.0 Educational baseline and are not qualified on a licensed profile. The + mechanism is *re-import*, not reading the key back out of the attribute. + +**Therefore the bar for `Present` is set higher than the bar for `Absent`, and +both are set higher than a resemblance.** `Present` requires identity; +`Absent` requires that no rule produced any candidate at all. Doubt in either +direction lands in `PossiblyPresent`, which authorises nothing and is handed to +a person. + +**This asymmetry has one dependency, and it is outside this contract.** It +holds only while the import writer derives a `REMOTEID` that is stable for a +business event and distinct between different ones. A key that collides across +two distinct events — a row ordinal within a re-downloaded window, say — turns +`REMOTEID`'s upsert from a safety property into a **silent overwrite of a +different voucher**, which lands on the same side of the ledger as a false +`Present`: no duplicate to see, no exception raised, nothing to find later. A +consumer acting on `Absent` inherits that risk from the writer, not from this +report. + +Any key proposed for that writer has to be exercised against two cases, and +**they are not symmetric in cost** — which decides what to do about each: + +- **Re-download.** The same window fetched twice collides two different events + onto one key, so one silently overwrites the other. That lands on the same + side as a false `Present`, and it is a reason to **refuse the key outright**. +- **Overlapping window.** Two fetches that share rows split one event across + two keys, so it posts twice. That is a visible, correctable duplicate — the + side this design already tolerates, and a reason to fix the key rather than + reject it. + +Both must be tested. Only the first disqualifies. + +The cost of this posture is operator review time. That is the intended cost: +the middle is where a human is genuinely faster than any rule, and the +alternative to reviewing it is an invisible omission or an invisible duplicate. + +### 8. What the book itself gives away + +Building the index makes three book-side observations free, and they are +reported alongside the verdicts rather than discarded: + +- `duplicate_numbers` — a (voucher type, number) that selects more than one + book voucher. This is the twenty-five-invoice finding, computed rather than + noticed. +- `unbalanced_vouchers` — a book voucher whose entries do not sum to zero. +- `unmatched_book_vouchers` — how many vouchers of a proposed type no proposal + matched **or resembled**. Counted only; listing them is a different report. + +These sit **outside** the paged rows, so a consumer's response machinery cannot +trim them: an unbounded echo here could push a complete report past a byte +budget that trimming rows could no longer rescue. The two echoed strings are +therefore bounded, and treated as what they are — **recognition labels, not +keys.** A group's identity is its `book_keys`, which are bounded by count. + +A voucher's magnitude is the sum of its positive entry amounts, computed in +exact decimal. It is defined whether or not the voucher balances, so an +unbalanced book voucher still participates in every amount rule — it is +reported, never excluded, because excluding it would make `Absent` *more* +likely, which is the wrong direction. + +### 9. Totals prove the run + +`PresenceReport::totals()` reports `requested`, `present`, `possibly_present`, +and `absent`, with `requested == present + possibly_present + absent` asserted +by test. This is the same control-total discipline that proved every clean +import engagement, and it is what lets an operator reconcile a generated file +against a source document by count alone. + +### 10. A verdict is a proposal, not an approval + +The report names book vouchers by an opaque caller-supplied key and names +masters by observed name only. It holds no company GUID and grants no +authority. A caller acting on `Absent` still goes through the unchanged +build-and-approve path, whose write gate — byte-exact master names, one +human-approved batch — this ADR does not move. + +## Consequences + +- `bridge_tally_core::book_presence` is new and is the only implementation. The + MCP tool `voucher_presence` is its first consumer; it performs the existing + qualified ledger-catalogue and `vouchers` window reads, builds a window from + the read state it can actually prove — `Partial` over a nonempty range — and + shapes the report through the same party-name marking and egress redaction as + every other read result. +- **Catalog coverage is byte-exact.** The typed boundary retains each observed + ledger and party spelling separately from its folded resemblance key, and + rejects a window whose exact spelling is absent from the catalog. A candidate + fold can never stand in for coverage. +- **The verdict is built from two independently timed reads, so the catalogue + is corroborated after the window.** A ledger renamed between them would let a + proposal bind the old name while the rows carry the new one, removing the + only resemblance and manufacturing an `Absent`. The adapter re-reads the + catalogue and refuses on drift, the same paired-snapshot rule the + selected-voucher read already applies. +- **Every refusal that depends only on the arguments happens before any Tally + request** — including the two cross-input ones, a proposal dated outside the + window and a voucher type absent from the numbering declaration. The crate + enforces them again at its own boundary; the adapter check exists so a + request that was always going to be refused does not first spend a company + probe, a catalogue read and a full window read. +- **The published `inputSchema` is enforced to its leaves, and by the schema + itself.** The shared argument validator bounds only outer arrays — every tool + predating nested inputs owns a typed boundary below that line, so tightening + the shared path would change their refusal codes — and the crate's own limits + are far wider than this tool advertises. The gap is closed by + `validate_against_schema`, a small recursive check that reads `type`, `enum`, + string and array bounds, `required` and `additionalProperties` straight from + the published fragment. Restating those limits in the parser would put two + copies of every bound in the tree, and the copy that drifts is the one nobody + is looking at. The helper lives beside the existing validator so the next + tool with a nested schema reuses it rather than restating anything. +- Voucher-type names preserve their validated spelling exactly: they are Tally + identity, not master names. Voucher numbers have a separate, deliberately + narrower key: outer transport whitespace trimming only. Internal whitespace, + case, punctuation, and Unicode form remain content until voucher-number + evidence establishes an equivalence; a broader fold could manufacture + `Present` for two distinct invoices. +- **The desktop source-draft flow is deliberately not wired yet, and the reason + is a shape gap rather than a scheduling one.** A draft row carries a + `source_remote_id`, a date, a voucher type and entries — but no voucher + number and no party field, and its voucher type is restricted to Payment, + Receipt, Journal and Contra. Of the two keys that can produce `Present`, the + number is absent from the draft and the `REMOTEID` is absent from the read + (below). Wiring a screen to a function that can only ever return + `PossiblyPresent` would misrepresent the capability. The crate is shared, and + the desktop consumes the same function once a draft row carries a number and + a party. +- **`RemoteId` is contract-complete and not reachable from the shipped read**, + so the adapter declares `RemoteIdEvidence::NotRead` and the tool's schema + does not accept a `remote_id` at all. `render_agent_vouchers` does not + `FETCH REMOTEID`; only the AlterID change feed does. Accepting an input that + could only ever *withhold* a verdict would be worse than refusing it. +- **The identity channel that does survive a round trip is the narration, and + this read already fetches it.** §9.8's batch-identity run recorded the + `REMOTEID` and the narration marker sharing one batch-derived UUID: Tally + overwrites the field it owns and leaves alone the field it does not, and + `render_agent_vouchers` fetches `NARRATION`. A marker a generator writes into + the narration is therefore readable back **today**, with no change to a + qualified read profile — which makes it the named path to a reachable + identity basis for vouchers Bridge itself wrote. Deliberately not built here: + a new basis is its own change, and this contract is under review. It is the + first thing to build on top of it. +- **A content fingerprint is never promoted to identity, which is the rule + `agent_import.rs` already enforces.** There a fingerprint-only match is + `matching_content_observed` and `posted_verified` needs a narration-tagged + match. Here date, party and amount can only ever produce candidates. The + hazard is identical in both: a company with a recurring same-day payment + already holds a voucher with that tuple, so the tuple would let a pre-existing + voucher stand in for one that was never written. Both motivating engagements were hand-keyed + and would not have had one regardless. +- The adapter requests the whole window before any comparison; `vouchers`' own + pagination bounds output, not Tally's work. That request is not evidence that + a nonempty response is complete, so the presence adapter records such a window + as `Partial` pending the source-side control total below, and no `Absent` can + issue from it. A window past `MAX_WINDOW_VOUCHERS` is refused with a + narrow-the-range error rather than silently truncated. +- **Nonempty window qualification is unavailable until the read has a source-side + control total.** A nonempty response is therefore represented as `Partial`. + It is still a window, and it still answers: `present` and `possibly_present` + are produced from it exactly as from a complete one, because neither needs + completeness. What it cannot issue is `Absent` — a proposal nothing in the + window resembled comes back `possibly_present` with reason + `window_not_proven_complete` instead. An empty window is the narrow + case the existing emptiness control can still corroborate `Complete`, and it + is therefore the only shape from which `absent` is reachable today. Three + other ways a window read can go wrong are closed: a transport or + source-limit failure never produces a window because the read itself fails; a + malformed or short body fails the strict parse; and the paired read refuses a + pair whose two responses differ. The case that remains open is a + **well-formed response that is silently short** — Tally answering a dense + window with fewer vouchers than it holds and saying nothing. No layer beneath + this contract detects that, and a deterministic short answer agrees with + itself across the pair, so pairing does not catch it either. Closing the + unavailable qualification needs a source-side control total — a count the + window read asserts about itself — and that is a separate read contract with + its own live evidence. + +- **A widened re-read was built, measured and rejected**, and the reasoning is + recorded here so the next attempt starts past it rather than at it. The idea + is to re-read the same range a day wider and compare the two reads on Tally's + own `GUID`s for the rows inside the original window: a row the wider read saw + and the narrow one did not proves the narrow read short. + + It does detect something real. A wider read is denser, so it truncates at + least as hard, and size-driven shortness is the case this section is about. + But it **cannot establish completeness**, for the reason stated two sentences + up in this same paragraph: a deterministic short answer agrees with itself. + If the added boundary days are empty, both reads drop the same suffix and + agree, and agreement is then indistinguishable from correctness. It converts + some false `Complete`s into `Partial`; it licenses none. + + The price is not small. Measured against a licensed 7.1 Silver book, a + one-day window reads 895,888 bytes and its widened corroboration reads + 1,744,152 — **1.95x**, taking one call from ~896 KB to ~2.6 MB, which roughly + halves the widest window the tool can serve before corroboration alone fails + it. Widening also moves the requested boundary, and on Education-mode Tally + an accepted boundary can widen into an unsupported one that Tally silently + reinterprets as the whole book. + + So: a detector with a real cost, a live correctness hazard, and no ability to + close the finding it was built for. The source-side control total above + remains the only thing that would. +- **The identifier rule that binds a party across spellings is bimodal, not + general.** Measured across three catalogs: zero of 470 names across sixteen + loaded synthetic companies, zero of 105 on one real book, and **91 of 214 — + about two in five — on another real book** whose operator embeds a contact + number in each customer's ledger name. So the rule has near-total coverage or + none at all depending on one operator's naming habit, and it is a bonus + signal a binder must work without rather than a key it may rely on. It is + decisive where it does fire: on that third book it resolved a customer that + the three closest name matches all got wrong. It is load bearing for + `master_binding`'s own consumers; it is deliberately **not** load + bearing here, because a party binding can never produce `Present` — it only + selects which names the resemblance rules compare, which widens the net. A + wrong bind can therefore cost a `SamePartyAmount` candidate and turn a + `PossiblyPresent` into an `Absent` — a visible, deletable duplicate — and can + never turn an `Absent` into a `Present`, which is the silent direction. That + is the asymmetry of §7 holding under a rule that is not yet proven. +- Presence is pure computation over already-observed data, but P1's live-evidence + requirement remains unmet: the two reads do not establish source completeness. + Its own tests are fabricated from a placeholder alphabet: they establish the + behaviour of the rules, and are not, and may not be presented as, evidence + about any Tally instance. + +- A prior owner-authorized, read-only replay exercised the decision rules using + proposals built from observed rows. It did not establish source completeness, + operational `Absent` capability, or a qualified nonempty window. The current + adapter therefore emits `present` and `possibly_present` verdicts from a + nonempty window but never `absent`: such a window is `Partial`, and the + verdict that would have been `absent` is reported as `possibly_present` with + reason `window_not_proven_complete`. The replay remains useful for + controlled rule characterization and for checking admissible perturbation + seeds; it is not merge evidence for a presence decision against a live + company. + +## Alternatives rejected + +- **A boolean `already_present`.** Every key available collides or is absent in + the cases that actually blocked. A boolean forces the collision to be + resolved by the code, silently, in whichever direction the author guessed. + The third status is the whole design. +- **Auto-resolving a sole candidate.** A single date-party-amount candidate is + the *most* seductive wrong answer, because it looks decisive. Uniqueness of a + resemblance is not identity; ADR 0016 rejected the same move for the same + reason. +- **Scoring candidates and thresholding.** A score invites a threshold, a + threshold auto-resolves, and here auto-resolution silently deletes an invoice + from a filed GST period. +- **Defaulting an undeclared numbering method to `Manual`.** It would make the + common case work and the automatic-numbering case fail invisibly, matching on + a number Tally discarded — §9.8's trap, re-implemented. +- **Defaulting it to `Unknown`.** Safe, but it means a caller who simply forgot + loses the only key that decides and is never told. Explicit ignorance is + cheap; implicit ignorance is not. +- **Fuzzy party matching inside this module.** It would fork the answer ADR + 0016 owns, and it was directly disproven on the case that mattered: the three + closest names were three different wrong people. +- **Deriving the numbering method from the window** (for example, inferring + `Automatic` from dense consecutive numbering). It is an inference about a + configuration, presented as an observation, and it decides whether the + strongest key is trusted. Exactly the shape of value this project has already + been burned by. + +### Unread remote identity and identity findings + +A proposed REMOTEID is stronger than every resemblance. If the window did not +fetch that column, nondecisive resemblance candidates use +remote_id_evidence_unavailable; their candidates and touched-book count remain +intact for review. A unique observed REMOTEID can identify a voucher across +exact voucher-type spellings, but the report must then carry a voucher_type +difference with both spellings rather than presenting an empty difference set. diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index b0961b10..60a4f488 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "f610a58ad2bb994388a4848b96cf789f2be536b715f82a74712ab677193f8e88", + "compatibility_surface_sha256": "c86a3689f0981f6015ddb908999701cb795bd8650330fd47d0d15414e5262986", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index f0959c39..d59de4e6 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -137,9 +137,13 @@ "path": "src-tauri/crates/bridge-tally-core/src/bills_reconciliation.rs", "sha256": "c3b0bfa66147b3e90800f0ccf3ede8425b6324c5025af4c0f033b123eb9fe63a" }, + { + "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", + "sha256": "652f57e441b05f4bdb25c8e53e9bcd9000641491e2fb8479c0a6344764b2b54c" + }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", - "sha256": "58674602eb3131c101ace7638d43cf550d74b29eb3848b0c908687bb2c9fbf9c" + "sha256": "7367c3c94f7412e1126b117239636a38678189ec89369c0d3494bd553d8e4e4b" }, { "path": "src-tauri/crates/bridge-tally-core/src/master_binding.rs", @@ -323,7 +327,11 @@ }, { "path": "src-tauri/src/agent.rs", - "sha256": "83824bf04e0d6b10b2cc3ed852a1600ef9472fe37966c6dce4d318efe58f1e9b" + "sha256": "2132f9735c9ffe79262ad84a83c99c899fde4c37a809420877e5db83dcd3bcc4" + }, + { + "path": "src-tauri/src/agent_catalog.rs", + "sha256": "242c00ddead06e2cebf46970703dc8c8f5831c096b0afd006bd844d9fb7e77f5" }, { "path": "src-tauri/src/agent_desktop_journal.rs", @@ -337,6 +345,14 @@ "path": "src-tauri/src/agent_ledgers.rs", "sha256": "a891e4ac51addc9dc17ae28ce4634bd2cfeb8f3f0df78e072290771d29a3131b" }, + { + "path": "src-tauri/src/agent_presence.rs", + "sha256": "44fa9f2482f1c1a76249bccdfa0b5e2626d816e9a405b721e04c8ef7da9d8ec5" + }, + { + "path": "src-tauri/src/agent_presence_tests.rs", + "sha256": "a96ebf07de0b998056c682c76cf7df734c1eaeb00a535410a228c1805269c899" + }, { "path": "src-tauri/src/agent_read_profiles.rs", "sha256": "cbcc6831046134158fad25ca3bbbda34f5060f0ab6d299b428f996e541b7e072" @@ -351,7 +367,7 @@ }, { "path": "src-tauri/src/agent_tests.rs", - "sha256": "48d636f5787f23ffd2e7963dc0c66fa77a05a9167e453e0aca3fb1c11e4df4b2" + "sha256": "c9d6b7722fe687d968c94c5669a1713a2aec276038fcd6facc47285d95e76726" }, { "path": "src-tauri/src/agent_trial_balance.rs", @@ -815,7 +831,7 @@ }, { "path": "tools/bridge-tally-compatibility/src/lib.rs", - "sha256": "91b726ce49d93068e8d6c16395e4c629d84c1800927245c65dc4aaea26ac4501" + "sha256": "993dfd51072f81d9f8b115d666de0d7830a10eec3a077d7fb690f6f206773b25" }, { "path": "tools/bridge-tally-compatibility/src/main.rs", @@ -850,5 +866,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "f610a58ad2bb994388a4848b96cf789f2be536b715f82a74712ab677193f8e88" + "manifest_sha256": "c86a3689f0981f6015ddb908999701cb795bd8650330fd47d0d15414e5262986" } \ No newline at end of file diff --git a/scripts/check-mcpb-bundle.py b/scripts/check-mcpb-bundle.py index d7934804..bc0c235a 100644 --- a/scripts/check-mcpb-bundle.py +++ b/scripts/check-mcpb-bundle.py @@ -16,7 +16,7 @@ RESOURCES = ("LICENSE", "NOTICE", "THIRD_PARTY_LICENSES.txt", "THIRD_PARTY_LICENSES_RUST.txt") DEFAULT_TOOLS = { "tally_status", "list_companies", "voucher_schema", "validate_masters", "outstandings", - "ledger_masters", "ledger_movement", "trial_balance", "vouchers", "read_evidence", "egress_log", "verify_import", + "ledger_masters", "ledger_movement", "trial_balance", "vouchers", "voucher_presence", "read_evidence", "egress_log", "verify_import", } MAX_BUNDLE_BYTES = 128 * 1024 * 1024 MAX_OUTPUT_BYTES = 512 * 1024 diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs new file mode 100644 index 00000000..85817224 --- /dev/null +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -0,0 +1,2278 @@ +//! Deterministic answer to "which of these proposed vouchers are already in +//! this company's book?" +//! +//! See `docs/adr/0017-voucher-presence-authority.md`. Tally has no idempotency +//! (`TALLY_PROTOCOL_REFERENCE.md` §9.3): re-sending a voucher creates a second +//! one, so this question stands between a generated batch and an import. +//! +//! Four rules carry the contract. Only an identity key — a `REMOTEID`, or a +//! voucher number on a voucher type declared `Manual` — can produce `Present`. +//! Nothing binds unless it is unique on both sides. `Absent` is only available +//! from a window proven complete and proven to cover the proposal. Everything +//! else is `PossiblyPresent`, which authorises nothing, carries no preferred +//! answer, and is handed to a person. +//! +//! Party matching is not reimplemented here: it is `master_binding`, whose +//! contract already owns "is this the same customer". +//! +//! This module performs no I/O, holds no company identity, and calls no model. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::exact_arithmetic::ExactDecimalAccumulator; +use crate::master_binding::{ + self, comparison_key, BindingStatus, Candidates, MasterBindingError, MasterCatalog, + MasterClass, SourceEntity, +}; +use crate::{ExactDecimal, TallyDate}; +use serde::{Deserialize, Serialize}; + +/// Most vouchers one observed window may carry. A window past this is refused +/// with a narrow-the-range error rather than silently compared in part. +pub const MAX_WINDOW_VOUCHERS: usize = 20_000; +/// Most vouchers one proposal set may carry. +pub const MAX_PROPOSED_VOUCHERS: usize = 5_000; +/// Maximum proposal/window pair comparisons admitted before resemblance work. +/// The individual bounds permit a product that would otherwise make the +/// indexed resemblance pass quadratic in the two untrusted collections. +pub const MAX_PRESENCE_COMPARISONS: usize = 1_000_000; +/// Aggregate indexed resemblance work units, including posting-list walks and +/// the party-key checks performed for every pooled voucher. +pub const MAX_PRESENCE_WORK_UNITS: usize = 5_000_000; +/// Most numbering declarations consumed for one presence request. +pub const MAX_NUMBERING_DECLARATIONS: usize = MAX_PROPOSED_VOUCHERS; +/// Aggregate UTF-8 bytes accepted while consuming numbering declarations. +pub const MAX_NUMBERING_DECLARATION_BYTES: usize = 1_048_576; +/// Most ledger entries one voucher may carry. +pub const MAX_ENTRIES_PER_VOUCHER: usize = 2_000; +/// Aggregate raw entries admitted before parsing, cloning, or folding them. +pub const MAX_WINDOW_RAW_ENTRY_WORK: usize = 100_000; +/// Aggregate raw entry bytes admitted before parsing, cloning, or folding them. +pub const MAX_WINDOW_RAW_ENTRY_BYTES: usize = 4 * 1024 * 1024; +/// Proposal input shares the same aggregate work and byte ceilings as a book +/// window. Admission happens while the borrowed input is still raw, before +/// decimal parsing or any string is cloned. +pub const MAX_PROPOSAL_RAW_ENTRY_WORK: usize = MAX_WINDOW_RAW_ENTRY_WORK; +pub const MAX_PROPOSAL_RAW_BYTES: usize = MAX_WINDOW_RAW_ENTRY_BYTES; +/// Most distinct voucher-to-ledger memberships retained across one window. +/// +/// `WindowIndex` must retain every membership once more to find party +/// resemblances. Per-voucher limits alone therefore admitted 40 million +/// memberships. The cap keeps that derived index bounded rather than relying +/// on an allocator failure after a complete-looking input was accepted. +pub const MAX_WINDOW_LEDGER_MEMBERSHIPS: usize = 100_000; +/// Most UTF-8 bytes in the distinct ledger comparison keys across one window. +/// +/// This separately bounds a smaller number of very long accepted keys; a +/// membership count alone cannot do that. +pub const MAX_WINDOW_LEDGER_KEY_BYTES: usize = 4 * 1024 * 1024; +/// Most candidates retained per undecided proposal. +pub const MAX_CANDIDATES_PER_PROPOSAL: usize = 25; +/// Most duplicate-number groups listed in the book observations. +pub const MAX_DUPLICATE_NUMBER_GROUPS: usize = 25; +/// Most book keys listed inside one duplicate-number group. +pub const MAX_KEYS_PER_DUPLICATE_GROUP: usize = 10; +/// Most unbalanced book vouchers listed in the book observations. +pub const MAX_UNBALANCED_LISTED: usize = 25; +/// Longest accepted book-voucher key. +/// +/// The key is echoed in every candidate, and a response can carry twenty-five +/// of them per proposal, so an unbounded key defeats any page budget: a +/// consumer's framing can drop whole rows but cannot shrink one. A Tally +/// voucher GUID is a 36-character company prefix and a short suffix, so this +/// is far above anything real and refuses only pathological input — and it +/// *refuses* rather than truncates, because a key is an identity and half of +/// one joins to nothing. +pub const MAX_BOOK_KEY_CHARS: usize = 128; + +/// Longest echoed label in the book observations. +/// +/// The observations sit outside the paged rows, so a consumer's response +/// machinery cannot trim them — an unbounded echo there can push a complete +/// report past a byte budget that trimming rows could no longer rescue. These +/// two fields are **recognition labels**, not keys: a group's identity is its +/// `book_keys`, which are bounded by count. +pub const MAX_OBSERVATION_LABEL_CHARS: usize = 128; +/// Appended to an echoed value that was longer than its bound, so a reader can +/// tell a shortened value from a whole one. See `label`. +pub const SHORTENED: char = '\u{2026}'; +/// Longest accepted text field, in characters. This bounds pathological input; +/// it is not a claim about what Tally accepts. +pub const MAX_TEXT_CHARS: usize = 16_384; + +/// Presence refuses rather than degrades. Every variant is a boundary check on +/// input that was never observed, never complete, or already undecidable +/// before any comparison ran. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum PresenceError { + #[error("book window range was invalid")] + WindowRangeInvalid, + #[error("book window exceeded its bound")] + WindowTooLarge, + #[error("book window ledger memberships exceeded their aggregate bound")] + WindowLedgerMembershipsTooMany, + #[error("book window raw entries exceeded their aggregate bound")] + WindowRawEntryWorkTooLarge, + #[error("book window raw entry bytes exceeded their aggregate bound")] + WindowRawEntryBytesTooLarge, + #[error("book window ledger keys exceeded their aggregate byte bound")] + WindowLedgerKeyBytesTooLarge, + #[error("book window carried a voucher dated outside its own range")] + WindowVoucherOutsideRange, + #[error("book window carried the same voucher key twice")] + WindowDuplicateVoucherKey, + /// A window declaring that `REMOTEID` was never read, carrying vouchers + /// that have one. The two statements contradict, and the contradiction + /// would let a verdict settle on evidence the window says was not gathered. + #[error("book window declared REMOTEID unread while carrying one")] + WindowRemoteIdContradiction, + #[error("book voucher key exceeded its bound")] + VoucherKeyTooLong, + /// A proposal dated outside the window would be judged against evidence + /// that could not contain it. + #[error("book window does not cover every proposed date")] + WindowDoesNotCover, + #[error("no vouchers were proposed")] + ProposalsEmpty, + #[error("proposed voucher list exceeded its bound")] + TooManyProposals, + #[error("two proposed vouchers carried the same source position")] + DuplicateProposalPosition, + #[error("proposed voucher raw entries exceeded their aggregate bound")] + ProposalRawEntryWorkTooLarge, + #[error("proposed voucher raw metadata exceeded its aggregate byte bound")] + ProposalRawBytesTooLarge, + #[error("proposal and book window comparison work exceeded its bound")] + ComparisonWorkTooLarge, + #[error("voucher entry list was empty")] + EntriesEmpty, + #[error("voucher entry list exceeded its bound")] + TooManyEntries, + /// A voucher type whose numbering method nobody stated. Defaulting it + /// would silently decide whether the only decisive key is usable. + #[error("a proposed voucher type has no declared numbering method")] + NumberingMethodUndeclared, + #[error("a voucher type was declared twice with different numbering")] + NumberingMethodConflict, + #[error("numbering declarations exceeded their count bound")] + NumberingDeclarationsTooMany, + #[error("numbering declarations exceeded their aggregate byte bound")] + NumberingDeclarationBytesTooLarge, + #[error("text field was blank")] + TextBlank, + #[error("text field exceeded its bound")] + TextTooLong, + #[error("text field carried a control character")] + TextUnsafe, + #[error("date was not a valid Tally date")] + DateInvalid, + #[error("amount was not an exact decimal")] + AmountInvalid, + /// Presence compares party names against ledgers. + #[error("master catalog was not a ledger catalog")] + CatalogClassInvalid, + #[error("book window referenced a ledger absent from the catalog")] + CatalogWindowCoverageMissing, + #[error("party binding refused the input")] + PartyBinding(MasterBindingError), +} + +impl PresenceError { + /// A stable code safe to surface to an operator or a tool result. + pub fn safe_reason_code(&self) -> &'static str { + match self { + Self::WindowRangeInvalid => "presence_window_range_invalid", + Self::WindowTooLarge => "presence_window_too_large", + Self::WindowLedgerMembershipsTooMany => "presence_window_ledger_memberships_too_many", + Self::WindowRawEntryWorkTooLarge => "presence_window_raw_entry_work_too_large", + Self::WindowRawEntryBytesTooLarge => "presence_window_raw_entry_bytes_too_large", + Self::WindowLedgerKeyBytesTooLarge => "presence_window_ledger_key_bytes_too_large", + Self::WindowVoucherOutsideRange => "presence_window_voucher_outside_range", + Self::WindowDuplicateVoucherKey => "presence_window_duplicate_voucher_key", + Self::WindowRemoteIdContradiction => "presence_window_remote_id_contradiction", + Self::VoucherKeyTooLong => "presence_voucher_key_too_long", + Self::WindowDoesNotCover => "presence_window_does_not_cover", + Self::ProposalsEmpty => "presence_proposals_empty", + Self::TooManyProposals => "presence_proposals_too_many", + Self::DuplicateProposalPosition => "presence_duplicate_proposal_position", + Self::ProposalRawEntryWorkTooLarge => "presence_proposal_raw_entry_work_too_large", + Self::ProposalRawBytesTooLarge => "presence_proposal_raw_bytes_too_large", + Self::ComparisonWorkTooLarge => "presence_comparison_work_too_large", + Self::EntriesEmpty => "presence_entries_empty", + Self::TooManyEntries => "presence_entries_too_many", + Self::NumberingMethodUndeclared => "presence_numbering_method_undeclared", + Self::NumberingMethodConflict => "presence_numbering_method_conflict", + Self::NumberingDeclarationsTooMany => "presence_numbering_declarations_too_many", + Self::NumberingDeclarationBytesTooLarge => { + "presence_numbering_declaration_bytes_too_large" + } + Self::TextBlank => "presence_text_blank", + Self::TextTooLong => "presence_text_too_long", + Self::TextUnsafe => "presence_text_unsafe", + Self::DateInvalid => "presence_date_invalid", + Self::AmountInvalid => "presence_amount_invalid", + Self::CatalogClassInvalid => "presence_catalog_class_invalid", + Self::CatalogWindowCoverageMissing => "presence_catalog_window_coverage_missing", + Self::PartyBinding(error) => error.safe_reason_code(), + } + } +} + +/// Whether the window's read gathered `REMOTEID` at all. A read profile that +/// does not fetch the field yields `NotRead`, which is a different fact from +/// "no voucher carried one" and must not be confused with it: a proposal whose +/// own `REMOTEID` was never compared cannot be reported `Absent`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RemoteIdEvidence { + /// The read fetched `REMOTEID`; an absent value means the voucher has none. + Observed, + /// The read did not fetch `REMOTEID`; absence means nothing at all. + NotRead, +} + +/// How completely the window's source read observed its range. Both values +/// become a `BookWindow` — a caller must state which it has rather than omit +/// the question — but only `Complete` may license `PresenceStatus::Absent`. +/// A window too dense to read, or one whose emptiness was only partly +/// corroborated, is not "no match found", and treating it as one is the +/// confusion most likely to turn into a duplicated invoice: `decide` degrades +/// a `Partial` window's would-be `Absent` to +/// `UndecidedReason::WindowNotProvenComplete` instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WindowRead { + Complete, + Partial, +} + +/// A voucher type's numbering method decides whether its voucher number is an +/// identity or a coincidence (§9.8). Under `Automatic`, Tally discards the +/// supplied number, so a number-based key is silently ineffective. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum NumberingMethod { + Manual, + Automatic, + /// Nobody has observed it. Legal, honest, and the common case; it demotes + /// the number from identity to resemblance. + Unknown, +} + +/// Whether an observed voucher has accounting effect. A cancelled or optional +/// voucher still occupies its number, so it can be matched and must never be +/// reported as a posted duplicate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PostingState { + Posted, + Cancelled, + Optional, +} + +/// One ledger entry, from either side of the comparison. The same function +/// derives a magnitude from both, so the two sides cannot compute one fact +/// differently. +#[derive(Debug, Clone, Copy)] +pub struct ObservedEntry<'a> { + pub ledger: &'a str, + pub amount: &'a str, +} + +/// One voucher as the book was observed to hold it. +#[derive(Debug, Clone, Copy)] +pub struct ObservedVoucher<'a> { + /// An opaque caller-owned key for this voucher. It is echoed back in the + /// report and never interpreted, so a caller chooses whatever it can join + /// on without granting this crate any identity. + pub key: &'a str, + pub date: &'a str, + pub voucher_type: &'a str, + pub voucher_number: Option<&'a str>, + pub remote_id: Option<&'a str>, + /// `PARTYLEDGERNAME`, when the read carried one. + pub party: Option<&'a str>, + pub entries: &'a [ObservedEntry<'a>], + pub cancelled: bool, + pub optional: bool, +} + +/// One voucher a source document proposes to import. +#[derive(Debug, Clone, Copy)] +pub struct ProposedVoucherInput<'a> { + pub position: usize, + pub date: &'a str, + pub voucher_type: &'a str, + pub voucher_number: Option<&'a str>, + pub remote_id: Option<&'a str>, + /// The party name exactly as the source document gives it. It is bound + /// through `master_binding`, never compared raw. + pub party: Option<&'a str>, + pub entries: &'a [ObservedEntry<'a>], +} + +/// Aggregate admission for a proposal batch. The input is borrowed so this +/// check runs before parsing decimals and before `ProposedVoucher` clones any +/// metadata. Callers that accept external proposal batches must use this +/// boundary rather than constructing a large converted vector first. +#[derive(Debug, Default)] +pub struct RawProposalBudget { + proposals: usize, + entries: usize, + entry_bytes: usize, + metadata_bytes: usize, +} + +impl RawProposalBudget { + pub fn admit(&mut self, input: ProposedVoucherInput<'_>) -> Result<(), PresenceError> { + self.admit_parts( + input.position, + input.date, + input.voucher_type, + input.voucher_number, + input.remote_id, + input.party, + input + .entries + .iter() + .map(|entry| (entry.ledger, entry.amount)), + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn admit_parts<'a>( + &mut self, + _position: usize, + date: &'a str, + voucher_type: &'a str, + voucher_number: Option<&'a str>, + remote_id: Option<&'a str>, + party: Option<&'a str>, + entries: impl IntoIterator, + ) -> Result<(), PresenceError> { + self.proposals = self + .proposals + .checked_add(1) + .ok_or(PresenceError::TooManyProposals)?; + if self.proposals > MAX_PROPOSED_VOUCHERS { + return Err(PresenceError::TooManyProposals); + } + let metadata = [ + date, + voucher_type, + voucher_number.unwrap_or_default(), + remote_id.unwrap_or_default(), + party.unwrap_or_default(), + ]; + let metadata_bytes = metadata + .iter() + .try_fold(0usize, |total, value| total.checked_add(value.len())) + .ok_or(PresenceError::ProposalRawBytesTooLarge)?; + self.metadata_bytes = self + .metadata_bytes + .checked_add(metadata_bytes) + .ok_or(PresenceError::ProposalRawBytesTooLarge)?; + if self.metadata_bytes > MAX_PROPOSAL_RAW_BYTES { + return Err(PresenceError::ProposalRawBytesTooLarge); + } + for (ledger, amount) in entries { + self.entries = self + .entries + .checked_add(1) + .ok_or(PresenceError::ProposalRawEntryWorkTooLarge)?; + if self.entries > MAX_PROPOSAL_RAW_ENTRY_WORK { + return Err(PresenceError::ProposalRawEntryWorkTooLarge); + } + self.entry_bytes = self + .entry_bytes + .checked_add(ledger.len()) + .and_then(|total| total.checked_add(amount.len())) + .ok_or(PresenceError::ProposalRawBytesTooLarge)?; + if self.entry_bytes > MAX_PROPOSAL_RAW_BYTES { + return Err(PresenceError::ProposalRawBytesTooLarge); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BookVoucher { + key: String, + date: TallyDate, + voucher_type: String, + voucher_number: Option, + remote_id: Option, + party: Option, + observed_ledgers: BTreeSet, + ledger_keys: BTreeSet, + magnitude: ExactDecimal, + balanced: bool, + posting: PostingState, + type_key: String, + number_key: Option, +} + +impl BookVoucher { + pub(crate) fn observed(input: ObservedVoucher<'_>) -> Result { + let key = validated_text(input.key)?; + if key.chars().count() > MAX_BOOK_KEY_CHARS { + return Err(PresenceError::VoucherKeyTooLong); + } + let date = + TallyDate::parse(input.date.to_string()).map_err(|_| PresenceError::DateInvalid)?; + let voucher_type = validated_text(input.voucher_type)?; + let voucher_number = input.voucher_number.map(validated_text).transpose()?; + let remote_id = input.remote_id.map(validated_text).transpose()?; + let party = input.party.map(validated_text).transpose()?; + let (magnitude, balanced, mut 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. + let type_key = voucher_type.clone(); + let number_key = voucher_number.as_deref().map(number_key_of); + Ok(Self { + key, + date, + voucher_type, + voucher_number, + remote_id, + party, + observed_ledgers, + ledger_keys, + magnitude, + balanced, + // A cancelled voucher is cancelled whatever else it is. + posting: match (input.cancelled, input.optional) { + (true, _) => PostingState::Cancelled, + (false, true) => PostingState::Optional, + (false, false) => PostingState::Posted, + }, + type_key, + number_key, + }) + } + + pub fn key(&self) -> &str { + &self.key + } + + pub fn date(&self) -> &str { + self.date.as_str() + } + + pub fn party(&self) -> Option<&str> { + self.party.as_deref() + } + + pub fn magnitude(&self) -> &ExactDecimal { + &self.magnitude + } + + /// Whether the observed entries summed to zero. An unbalanced voucher is + /// reported and still participates in every rule: excluding it would make + /// `Absent` more likely, which is the wrong direction. + pub fn balanced(&self) -> bool { + self.balanced + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProposedVoucher { + position: usize, + date: TallyDate, + voucher_type: String, + voucher_number: Option, + remote_id: Option, + party: Option, + magnitude: ExactDecimal, + type_key: String, + number_key: Option, +} + +impl ProposedVoucher { + #[cfg(test)] + pub(crate) fn new(input: ProposedVoucherInput<'_>) -> Result { + let mut budget = RawProposalBudget::default(); + budget.admit(input)?; + Self::new_admitted(input) + } + + fn new_admitted(input: ProposedVoucherInput<'_>) -> Result { + let date = + TallyDate::parse(input.date.to_string()).map_err(|_| PresenceError::DateInvalid)?; + let voucher_type = validated_text(input.voucher_type)?; + let voucher_number = input.voucher_number.map(validated_text).transpose()?; + let remote_id = input.remote_id.map(validated_text).transpose()?; + let party = input.party.map(validated_text).transpose()?; + let (magnitude, _, _, _) = magnitude_of(input.entries)?; + let type_key = voucher_type.clone(); + let number_key = voucher_number.as_deref().map(number_key_of); + Ok(Self { + position: input.position, + date, + voucher_type, + voucher_number, + remote_id, + party, + magnitude, + type_key, + number_key, + }) + } + + /// Convert a raw proposal batch only after one aggregate admission pass. + pub fn from_inputs<'a>( + inputs: impl IntoIterator>, + ) -> Result { + let mut budget = RawProposalBudget::default(); + let mut positions = BTreeSet::new(); + let mut converted = Vec::new(); + for input in inputs { + budget.admit(input)?; + if !positions.insert(input.position) { + return Err(PresenceError::DuplicateProposalPosition); + } + converted.push(Self::new_admitted(input)?); + } + Ok(ProposedBatch { + vouchers: converted, + }) + } + + pub fn date(&self) -> &str { + self.date.as_str() + } + + /// The type as the source document spelled it. A consumer validating its + /// own arguments against a `NumberingDeclaration` needs this; nothing else + /// does. + pub fn voucher_type(&self) -> &str { + &self.voucher_type + } + + /// The party name as the source document spelled it, and this proposal's + /// place in the batch. An adapter that wants to refuse a malformed party + /// *before* it spends a read needs both, because the entity parse that + /// would refuse it otherwise happens inside `PresenceRequest::new`. + pub fn party(&self) -> Option<&str> { + self.party.as_deref() + } + + pub fn position(&self) -> usize { + self.position + } +} + +/// An admitted proposal batch. Its only production constructor performs the +/// aggregate raw admission before conversion, so callers cannot concatenate +/// independently converted vectors and evade the batch budget. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProposedBatch { + vouchers: Vec, +} + +impl ProposedBatch { + pub fn as_slice(&self) -> &[ProposedVoucher] { + &self.vouchers + } + + pub fn iter(&self) -> impl Iterator { + self.vouchers.iter() + } +} + +/// One observed window of a company's book. A window whose read was only +/// `Partial` is still admitted: `Present` and `PossiblyPresent` need no +/// completeness proof, only `Absent` does, and that gate lives at verdict +/// production (`assess`/`decide`), keyed off `read()`, rather than here. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BookWindow { + from: TallyDate, + to: TallyDate, + read: WindowRead, + remote_id_evidence: RemoteIdEvidence, + vouchers: Vec, +} + +/// Incremental admission for raw voucher rows. Both adapters and the core +/// window boundary use this before retaining entry descriptors. +#[derive(Debug, Default)] +pub struct RawObservationBudget { + vouchers: usize, + entries: usize, + bytes: usize, + metadata_bytes: usize, +} + +impl RawObservationBudget { + #[allow(clippy::too_many_arguments)] + pub fn admit_fields<'a>( + &mut self, + key: &'a str, + date: &'a str, + voucher_type: &'a str, + voucher_number: Option<&'a str>, + remote_id: Option<&'a str>, + party: Option<&'a str>, + entries: impl IntoIterator, + ) -> Result<(), PresenceError> { + let metadata = [ + key, + date, + voucher_type, + voucher_number.unwrap_or_default(), + remote_id.unwrap_or_default(), + party.unwrap_or_default(), + ]; + let metadata_bytes = metadata + .iter() + .try_fold(0usize, |total, value| total.checked_add(value.len())) + .ok_or(PresenceError::WindowRawEntryBytesTooLarge)?; + self.metadata_bytes = self + .metadata_bytes + .checked_add(metadata_bytes) + .ok_or(PresenceError::WindowRawEntryBytesTooLarge)?; + if self.metadata_bytes > MAX_WINDOW_RAW_ENTRY_BYTES { + return Err(PresenceError::WindowRawEntryBytesTooLarge); + } + self.admit(entries) + } + + pub fn admit_observation( + &mut self, + observation: &ObservedVoucher<'_>, + ) -> Result<(), PresenceError> { + self.admit_fields( + observation.key, + observation.date, + observation.voucher_type, + observation.voucher_number, + observation.remote_id, + observation.party, + observation + .entries + .iter() + .map(|entry| (entry.ledger, entry.amount)), + ) + } + + pub fn admit<'a>( + &mut self, + entries: impl IntoIterator, + ) -> Result<(), PresenceError> { + self.vouchers = self + .vouchers + .checked_add(1) + .ok_or(PresenceError::WindowTooLarge)?; + if self.vouchers > MAX_WINDOW_VOUCHERS { + return Err(PresenceError::WindowTooLarge); + } + for (ledger, amount) in entries { + self.entries = self + .entries + .checked_add(1) + .ok_or(PresenceError::WindowRawEntryWorkTooLarge)?; + if self.entries > MAX_WINDOW_RAW_ENTRY_WORK { + return Err(PresenceError::WindowRawEntryWorkTooLarge); + } + self.bytes = self + .bytes + .checked_add(ledger.len()) + .and_then(|n| n.checked_add(amount.len())) + .ok_or(PresenceError::WindowRawEntryBytesTooLarge)?; + if self.bytes > MAX_WINDOW_RAW_ENTRY_BYTES { + return Err(PresenceError::WindowRawEntryBytesTooLarge); + } + } + Ok(()) + } +} + +impl BookWindow { + /// Admits raw observations in aggregate before the per-voucher conversion + /// performs decimal parsing, string cloning, and comparison-key folding. + pub fn from_observations<'a>( + from: &str, + to: &str, + read: WindowRead, + remote_id_evidence: RemoteIdEvidence, + observations: impl IntoIterator>, + ) -> Result { + let mut budget = RawObservationBudget::default(); + let mut vouchers = Vec::new(); + for observation in observations { + budget.admit_observation(&observation)?; + vouchers.push(BookVoucher::observed(observation)?); + } + Self::observed(from, to, read, remote_id_evidence, vouchers) + } + + pub(crate) fn observed( + from: &str, + to: &str, + read: WindowRead, + remote_id_evidence: RemoteIdEvidence, + vouchers: Vec, + ) -> Result { + let from = TallyDate::parse(from.to_string()).map_err(|_| PresenceError::DateInvalid)?; + let to = TallyDate::parse(to.to_string()).map_err(|_| PresenceError::DateInvalid)?; + if from.as_str() > to.as_str() { + return Err(PresenceError::WindowRangeInvalid); + } + if vouchers.len() > MAX_WINDOW_VOUCHERS { + return Err(PresenceError::WindowTooLarge); + } + let mut keys = BTreeSet::new(); + let mut ledger_memberships = 0usize; + let mut ledger_key_bytes = 0usize; + for voucher in &vouchers { + if voucher.date() < from.as_str() || voucher.date() > to.as_str() { + return Err(PresenceError::WindowVoucherOutsideRange); + } + if !keys.insert(voucher.key()) { + return Err(PresenceError::WindowDuplicateVoucherKey); + } + if remote_id_evidence == RemoteIdEvidence::NotRead && voucher.remote_id.is_some() { + return Err(PresenceError::WindowRemoteIdContradiction); + } + let retained_memberships = voucher + .ledger_keys + .len() + .checked_add(voucher.observed_ledgers.len()) + .ok_or(PresenceError::WindowLedgerMembershipsTooMany)?; + ledger_memberships = ledger_memberships + .checked_add(retained_memberships) + .ok_or(PresenceError::WindowLedgerMembershipsTooMany)?; + if ledger_memberships > MAX_WINDOW_LEDGER_MEMBERSHIPS { + return Err(PresenceError::WindowLedgerMembershipsTooMany); + } + let voucher_key_bytes = voucher + .ledger_keys + .iter() + .chain(voucher.observed_ledgers.iter()) + .try_fold(0usize, |total, key| total.checked_add(key.len())) + .ok_or(PresenceError::WindowLedgerKeyBytesTooLarge)?; + ledger_key_bytes = ledger_key_bytes + .checked_add(voucher_key_bytes) + .ok_or(PresenceError::WindowLedgerKeyBytesTooLarge)?; + if ledger_key_bytes > MAX_WINDOW_LEDGER_KEY_BYTES { + return Err(PresenceError::WindowLedgerKeyBytesTooLarge); + } + } + Ok(Self { + from, + to, + read, + remote_id_evidence, + vouchers, + }) + } + + pub fn from(&self) -> &str { + self.from.as_str() + } + + pub fn to(&self) -> &str { + self.to.as_str() + } + + pub fn vouchers(&self) -> &[BookVoucher] { + &self.vouchers + } + + /// Whether this window's source read observed its whole range. Only + /// `Complete` may license `PresenceStatus::Absent`; see `decide`. + pub fn read(&self) -> WindowRead { + self.read + } + + pub fn remote_id_evidence(&self) -> RemoteIdEvidence { + self.remote_id_evidence + } + + fn covers(&self, date: &str) -> bool { + date >= self.from.as_str() && date <= self.to.as_str() + } +} + +/// The numbering method of every voucher type a proposal names. A type that is +/// missing is an error, not a default: the declaration decides whether the only +/// decisive key is usable at all. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct NumberingDeclaration { + methods: BTreeMap, +} + +impl NumberingDeclaration { + pub fn new(entries: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let mut methods = BTreeMap::new(); + let mut declaration_count = 0usize; + let mut declaration_bytes = 0usize; + for (voucher_type, method) in entries { + let key = validated_text(voucher_type.as_ref())?; + declaration_count = declaration_count + .checked_add(1) + .ok_or(PresenceError::NumberingDeclarationsTooMany)?; + if declaration_count > MAX_NUMBERING_DECLARATIONS { + return Err(PresenceError::NumberingDeclarationsTooMany); + } + declaration_bytes = declaration_bytes + .checked_add(key.len()) + .ok_or(PresenceError::NumberingDeclarationBytesTooLarge)?; + if declaration_bytes > MAX_NUMBERING_DECLARATION_BYTES { + return Err(PresenceError::NumberingDeclarationBytesTooLarge); + } + if methods + .insert(key, method) + .is_some_and(|prior| prior != method) + { + return Err(PresenceError::NumberingMethodConflict); + } + } + Ok(Self { methods }) + } + + /// Whether a voucher type has a declared method, without needing the + /// caller to reproduce this crate's comparison key. A consumer validating + /// its own arguments before performing a read uses this. + pub fn declares(&self, voucher_type: &str) -> bool { + self.methods.contains_key(voucher_type) + } + + fn method(&self, type_key: &str) -> Option { + self.methods.get(type_key).copied() + } +} + +/// How a proposal's party name resolved against the observed ledger catalog. +/// It reports the binding and nothing more: an ambiguous party's candidates +/// belong to `validate_masters`, which owns that vocabulary, and naming one of +/// them here would be the auto-resolution ADR 0016 forbids. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case", tag = "party_state")] +pub enum PartyOutcome { + NotSupplied, + Bound { + catalog_name: String, + }, + Ambiguous { + reason: String, + candidate_count: usize, + }, + Unmatched { + reason: String, + }, +} + +/// The evidence that decided a `Present`. Both are identity. Date, amount and +/// party are never a basis; they are the keys that measurably collide. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PresenceBasis { + RemoteId, + ManualVoucherNumber, +} + +/// The rule that surfaced a candidate. Ordered by `rank`, never by similarity, +/// and no candidate is marked best. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CandidateRule { + SharedRemoteId, + SharedVoucherNumber, + SameDatePartyAmount, + SamePartyAmount, + SameDateAmount, + SameDateParty, +} + +impl CandidateRule { + fn rank(self) -> u8 { + match self { + Self::SharedRemoteId => 0, + Self::SharedVoucherNumber => 1, + Self::SameDatePartyAmount => 2, + Self::SamePartyAmount => 3, + Self::SameDateAmount => 4, + Self::SameDateParty => 5, + } + } +} + +/// A book voucher an operator may judge, with the rule that surfaced it. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct PresenceCandidate { + pub book_key: String, + pub rule: CandidateRule, +} + +/// Why a proposal was not decided. Exactly one, by the precedence in +/// `assess`: a collision outranks a resemblance, and a resemblance outranks an +/// incomplete party comparison. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum UndecidedReason { + /// One `REMOTEID` is carried by more than one voucher on either side. + RemoteIdCollision, + /// The number selects more than one book voucher. This is the book that + /// held twenty-five invoices sharing numbers in one month. + BookNumberCollision, + /// More than one proposal claims the number under manual numbering. + ProposalNumberCollision, + /// An identity key landed on a cancelled or optional voucher. It occupies + /// the number but has no accounting effect. + MatchedVoucherNotPosted, + /// A number matched, but the voucher type is not declared `Manual`, so the + /// number is not identity (§9.8). + NumberNotDecisive, + /// A number matched under a voucher type this window never observed. The + /// number was compared across every type rather than manufacture an + /// absence, so it is a resemblance and not a series position. + VoucherTypeNotObserved, + /// Date, party or amount resembles a book voucher. These collide in real + /// data and never decide. + ResemblesBookVoucher, + /// The party comparison could not be completed, so no rule that needs a + /// party actually ran and `Absent` is not available. + PartyNotDecidable, + /// The voucher type is declared `Manual`, so the number is the one key + /// that could decide — and the source supplied none. Nothing was skipped, + /// and nothing decisive was offered either, so the absence would rest on + /// resemblance alone. + ManualNumberNotSupplied, + /// The source named no party at all. Nothing was skipped — but nothing was + /// compared either, and a book voucher for the same party and amount on + /// another date would never have surfaced. `Present` is still reachable by + /// identity; only the absence claim is withheld, and supplying the party + /// is what makes it available. + PartyNotSupplied, + /// Two proposals both resolved to the same book voucher, possibly by + /// different identity bases. One book voucher can satisfy at most one + /// proposal, so every claimant is demoted rather than one being chosen. + BookVoucherClaimedTwice, + /// A number matched uniquely while the two sides carried *different* + /// `REMOTEID`s. Two identity signals disagree, and a disagreement is + /// reported rather than settled in the number's favour. + IdentityConflict, + /// The proposal carries a `REMOTEID` the window never read, so the + /// strongest key available to this proposal was never compared. An + /// `Absent` here would rest on evidence that was not gathered. + RemoteIdEvidenceUnavailable, + /// Nothing resembled the proposal, and every other decisive key was + /// either absent or already compared — but the window's own read was + /// only `Partial`. `Absent` means "not anywhere in this window", and + /// that claim is unavailable from a window not proven to cover its whole + /// declared range: what looks like "no match found" may only be "no + /// match found in the part that was read". + WindowNotProvenComplete, +} + +impl UndecidedReason { + /// A stable code safe to surface to an operator or a tool result. + pub fn safe_reason_code(self) -> &'static str { + match self { + Self::RemoteIdCollision => "presence_remote_id_collision", + Self::BookNumberCollision => "presence_book_number_collision", + Self::ProposalNumberCollision => "presence_proposal_number_collision", + Self::MatchedVoucherNotPosted => "presence_matched_voucher_not_posted", + Self::NumberNotDecisive => "presence_number_not_decisive", + Self::VoucherTypeNotObserved => "presence_voucher_type_not_observed", + Self::ResemblesBookVoucher => "presence_resembles_book_voucher", + Self::PartyNotDecidable => "presence_party_not_decidable", + Self::PartyNotSupplied => "presence_party_not_supplied", + Self::ManualNumberNotSupplied => "presence_manual_number_not_supplied", + Self::BookVoucherClaimedTwice => "presence_book_voucher_claimed_twice", + Self::IdentityConflict => "presence_identity_conflict", + Self::RemoteIdEvidenceUnavailable => "presence_remote_id_evidence_unavailable", + Self::WindowNotProvenComplete => "presence_window_not_proven_complete", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DifferenceField { + VoucherType, + Date, + Amount, + Party, +} + +/// A field on which an identified voucher disagrees with its source. The match +/// was decided by identity, so a difference is a finding about the book — not +/// evidence against the match. +/// +/// **It is a finding for a person, and the obvious way to act on it in code is +/// destructive.** On the observed instance a voucher `Alter` returns +/// `CREATED=1, ALTERED=0` and makes a duplicate while leaving the target +/// untouched (`TALLY_PROTOCOL_REFERENCE.md` §9.7, four keys tested and all four +/// duplicating), and `Cancel` behaves the same way (§9.6). A caller that reads +/// "amount differs" and reaches for an `Alter` creates the duplicate this whole +/// contract exists to prevent, and Tally's counters report success. The only +/// correction that works is re-import under the same client `REMOTEID` +/// (`IMPLEMENTATION_GUIDE.md` §3.3a), which reaches only vouchers Bridge itself +/// wrote — so for the hand-keyed voucher this contract is built for there is no +/// programmatic correction path at all, and the operator fixes it in Tally. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Difference { + pub field: DifferenceField, + pub proposed: Option, + pub observed: Option, +} + +/// What could not be decided, and why. This is the operator's work item, not +/// an error path — and it carries no field that names a match. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Undecided { + pub reason: UndecidedReason, + pub candidates: Vec, + /// Candidates found before truncation. + pub candidate_count: usize, + pub candidates_truncated: bool, +} + +/// Exactly one outcome per proposed voucher. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case", tag = "presence")] +pub enum PresenceStatus { + /// An identity key matched uniquely on both sides. This is the only status + /// that names a book voucher, and the only one that authorises excluding a + /// voucher from an import. + Present { + book_key: String, + basis: PresenceBasis, + differences: Vec, + }, + /// Something resembles it, or something prevented a decision. Authorises + /// nothing. + PossiblyPresent(Undecided), + /// No rule produced any candidate, in a window proven to cover the + /// proposal's date **and** proven to have been read completely + /// (`WindowRead::Complete`). `Absent` is always relative to that window. + /// A window read only `Partial` degrades this to `PossiblyPresent( + /// UndecidedReason::WindowNotProvenComplete)` instead — see `decide`. + Absent, +} + +/// One proposed voucher and its outcome. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct VoucherPresence { + pub position: usize, + /// The voucher number exactly as the source document gave it. + pub voucher_number: Option, + pub numbering_method: NumberingMethod, + /// Whether this proposal's voucher type was observed anywhere in the + /// window. When it was not, type stops discriminating and number matching + /// widens to every observed type — narrowing on an unobserved type name + /// would manufacture absence. + pub voucher_type_observed: bool, + pub party: PartyOutcome, + #[serde(flatten)] + pub status: PresenceStatus, +} + +impl VoucherPresence { + pub fn present_book_key(&self) -> Option<&str> { + match &self.status { + PresenceStatus::Present { book_key, .. } => Some(book_key.as_str()), + _ => None, + } + } + + pub fn undecided(&self) -> Option<&Undecided> { + match &self.status { + PresenceStatus::PossiblyPresent(undecided) => Some(undecided), + _ => None, + } + } + + pub fn is_absent(&self) -> bool { + matches!(self.status, PresenceStatus::Absent) + } +} + +/// A (voucher type, number) pair that identifies more than one book voucher. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct DuplicateNumberGroup { + pub voucher_type: String, + pub voucher_number: String, + pub book_keys: Vec, + pub book_voucher_count: usize, +} + +/// What the book gave away while it was being indexed. These cost nothing to +/// compute and one of them is a filed-return problem. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct BookObservations { + pub duplicate_numbers: Vec, + pub duplicate_number_group_count: usize, + pub duplicate_numbers_truncated: bool, + pub unbalanced_vouchers: Vec, + pub unbalanced_voucher_count: usize, + /// Vouchers of a proposed voucher type that no proposal matched or even + /// resembled — the other half of a reconciliation. Counted, not listed. + pub unmatched_book_vouchers: usize, + pub window_voucher_count: usize, + /// Whether any observed voucher carried a `REMOTEID` at all. Without this, + /// an absence of remote-id matches reads as evidence that none exist. + pub remote_id_observed: bool, +} + +/// Control totals for one run. `requested == present + possibly_present + +/// absent` always holds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub struct PresenceTotals { + pub requested: usize, + pub present: usize, + pub possibly_present: usize, + pub absent: usize, +} + +/// The result of one presence run, scoped to the window it was computed over. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct PresenceReport { + window_from: String, + window_to: String, + vouchers: Vec, + observations: BookObservations, +} + +impl PresenceReport { + /// The window every verdict is relative to. `Absent` means absent from + /// this range, never absent from the book. + pub fn window(&self) -> (&str, &str) { + (&self.window_from, &self.window_to) + } + + pub fn vouchers(&self) -> &[VoucherPresence] { + &self.vouchers + } + + pub fn observations(&self) -> &BookObservations { + &self.observations + } + + /// The vouchers an import may carry. Nothing else is safe to include + /// without a person. + pub fn absent(&self) -> impl Iterator { + self.vouchers.iter().filter(|entry| entry.is_absent()) + } + + pub fn present(&self) -> impl Iterator { + self.vouchers + .iter() + .filter(|entry| entry.present_book_key().is_some()) + } + + pub fn possibly_present(&self) -> impl Iterator { + self.vouchers + .iter() + .filter(|entry| entry.undecided().is_some()) + } + + pub fn totals(&self) -> PresenceTotals { + let present = self.present().count(); + let possibly_present = self.possibly_present().count(); + let absent = self.absent().count(); + PresenceTotals { + requested: self.vouchers.len(), + present, + possibly_present, + absent, + } + } +} + +/// Already-valid inputs for one presence run. Every cross-input refusal — the +/// window covering the proposals, a declared numbering method for every +/// proposed type, a ledger catalog, the party binding itself — happens here, +/// so `assess` cannot fail and no caller can compensate differently. +#[derive(Debug)] +pub struct PresenceRequest<'a> { + window: &'a BookWindow, + numbering: &'a NumberingDeclaration, + proposals: &'a [ProposedVoucher], + party_bindings: Vec, +} + +/// A bound party reduced to what the rules need: the names to compare against, +/// and whether the comparison was complete enough to justify `Absent`. +#[derive(Debug, Clone, PartialEq, Eq)] +struct PartyResolution { + outcome: PartyOutcome, + compare_keys: BTreeSet, + /// True when names that might have matched were never compared. + incomplete: bool, +} + +impl<'a> PresenceRequest<'a> { + #[cfg(not(test))] + pub fn new( + window: &'a BookWindow, + catalog: &'a MasterCatalog, + numbering: &'a NumberingDeclaration, + proposals: &'a ProposedBatch, + ) -> Result { + Self::new_inner(window, catalog, numbering, proposals.as_slice()) + } + + #[cfg(test)] + pub fn new( + window: &'a BookWindow, + catalog: &'a MasterCatalog, + numbering: &'a NumberingDeclaration, + proposals: &'a [ProposedVoucher], + ) -> Result { + Self::new_inner(window, catalog, numbering, proposals) + } + + fn new_inner( + window: &'a BookWindow, + catalog: &'a MasterCatalog, + numbering: &'a NumberingDeclaration, + proposals: &'a [ProposedVoucher], + ) -> Result { + if catalog.class() != MasterClass::Ledger { + return Err(PresenceError::CatalogClassInvalid); + } + if window + .vouchers() + .iter() + .flat_map(|voucher| voucher.observed_ledgers.iter()) + .any(|ledger| catalog.exact(ledger).is_none()) + { + return Err(PresenceError::CatalogWindowCoverageMissing); + } + if proposals.is_empty() { + return Err(PresenceError::ProposalsEmpty); + } + if proposals.len() > MAX_PROPOSED_VOUCHERS { + return Err(PresenceError::TooManyProposals); + } + let mut positions = BTreeSet::new(); + if proposals + .iter() + .any(|proposal| !positions.insert(proposal.position())) + { + return Err(PresenceError::DuplicateProposalPosition); + } + let comparisons = proposals + .len() + .checked_mul(window.vouchers().len()) + .ok_or(PresenceError::ComparisonWorkTooLarge)?; + if comparisons > MAX_PRESENCE_COMPARISONS { + return Err(PresenceError::ComparisonWorkTooLarge); + } + for proposal in proposals { + if !window.covers(proposal.date()) { + return Err(PresenceError::WindowDoesNotCover); + } + if numbering.method(&proposal.type_key).is_none() { + return Err(PresenceError::NumberingMethodUndeclared); + } + } + let party_bindings = bind_parties(catalog, proposals)?; + let index = WindowIndex::build(window); + if resemblance_work_units(proposals, &party_bindings, &index)? > MAX_PRESENCE_WORK_UNITS { + return Err(PresenceError::ComparisonWorkTooLarge); + } + Ok(Self { + window, + numbering, + proposals, + party_bindings, + }) + } +} + +/// Binds every distinct proposed party name through `master_binding`, once, and +/// reduces each to the names the rules may compare against. +fn bind_parties( + catalog: &MasterCatalog, + proposals: &[ProposedVoucher], +) -> Result, PresenceError> { + let mut distinct: Vec<&str> = proposals + .iter() + .filter_map(|proposal| proposal.party.as_deref()) + .collect(); + distinct.sort_unstable(); + distinct.dedup(); + let entities = distinct + .iter() + .enumerate() + .map(|(position, name)| SourceEntity::new(position, name)) + .collect::, _>>() + .map_err(PresenceError::PartyBinding)?; + let report = master_binding::bind(catalog, &entities).map_err(PresenceError::PartyBinding)?; + let resolved = distinct + .iter() + .zip(report.entities()) + .map(|(name, binding)| ((*name).to_string(), resolution_of(binding))) + .collect::>(); + Ok(proposals + .iter() + .map(|proposal| match proposal.party.as_deref() { + None => PartyResolution { + outcome: PartyOutcome::NotSupplied, + compare_keys: BTreeSet::new(), + // No party was skipped, and none was compared. The party rules + // could not run at all, so an absence rests on the date and + // amount alone — which is the pair this contract says collides. + incomplete: true, + }, + Some(name) => resolved + .get(name) + .cloned() + .expect("every proposed party name was bound"), + }) + .collect()) +} + +fn resolution_of(binding: &master_binding::EntityBinding) -> PartyResolution { + // Matched exhaustively rather than read through accessors: these four + // cases are the reason ADR 0016 replaced a vector plus two flags with a + // type, and a new one must not compile until this decides what it means. + // `listed` is empty for `None` and `Withheld` alike, so the difference + // between "nothing resembles this party" and "a family we refuse to slice" + // lives only here. + fn from(unresolved: &master_binding::Unresolved) -> (BTreeSet, bool) { + let keys = |listed: &[master_binding::Candidate]| { + listed + .iter() + .map(|candidate| comparison_key(&candidate.catalog_name)) + .collect::>() + }; + match &unresolved.candidates { + // Nothing resembles the party, and that is information. + Candidates::None => (BTreeSet::new(), false), + Candidates::Listed { listed } => (keys(listed), false), + // Names exist that were never compared, either way. + Candidates::Truncated { listed, .. } => (keys(listed), true), + Candidates::Withheld { .. } => (BTreeSet::new(), true), + } + } + match &binding.status { + BindingStatus::Bound { catalog_name, .. } => PartyResolution { + outcome: PartyOutcome::Bound { + catalog_name: catalog_name.clone(), + }, + compare_keys: BTreeSet::from([comparison_key(catalog_name)]), + incomplete: false, + }, + // Every candidate is compared, never one of them. Widening the net can + // only produce more resemblance, which is the safe direction here. + BindingStatus::Ambiguous(unresolved) => { + let (compare_keys, incomplete) = from(unresolved); + PartyResolution { + outcome: PartyOutcome::Ambiguous { + reason: unresolved.reason.safe_reason_code().to_string(), + candidate_count: unresolved.candidates.found(), + }, + compare_keys, + incomplete, + } + } + // Nothing in this book resembles the party, so no posted voucher can + // be carrying it. Party rules simply do not run. + BindingStatus::Unmatched(unresolved) => { + let (compare_keys, incomplete) = from(unresolved); + PartyResolution { + outcome: PartyOutcome::Unmatched { + reason: unresolved.reason.safe_reason_code().to_string(), + }, + compare_keys, + incomplete, + } + } + } +} + +/// Indexes of one window, built once per run. +struct WindowIndex<'a> { + by_remote_id: BTreeMap<&'a str, Vec>, + by_type_and_number: BTreeMap<(&'a str, &'a str), Vec>, + by_number: BTreeMap<&'a str, Vec>, + by_date: BTreeMap<&'a str, Vec>, + by_ledger: BTreeMap<&'a str, Vec>, + type_keys: BTreeSet<&'a str>, +} + +impl<'a> WindowIndex<'a> { + fn build(window: &'a BookWindow) -> Self { + let mut index = Self { + by_remote_id: BTreeMap::new(), + by_type_and_number: BTreeMap::new(), + by_number: BTreeMap::new(), + by_date: BTreeMap::new(), + by_ledger: BTreeMap::new(), + type_keys: BTreeSet::new(), + }; + for (position, voucher) in window.vouchers.iter().enumerate() { + index.type_keys.insert(voucher.type_key.as_str()); + if let Some(remote_id) = voucher.remote_id.as_deref() { + index + .by_remote_id + .entry(remote_id) + .or_default() + .push(position); + } + if let Some(number_key) = voucher.number_key.as_deref() { + index + .by_type_and_number + .entry((voucher.type_key.as_str(), number_key)) + .or_default() + .push(position); + index + .by_number + .entry(number_key) + .or_default() + .push(position); + } + index + .by_date + .entry(voucher.date.as_str()) + .or_default() + .push(position); + for ledger in &voucher.ledger_keys { + index + .by_ledger + .entry(ledger.as_str()) + .or_default() + .push(position); + } + } + index + } +} + +/// Conservatively prices the indexed resemblance pass. The bound includes +/// each proposal's date posting list, every party-key posting list, the pooled +/// voucher checks, and the per-pooled-voucher `any` over party keys. It applies +/// even when an identity path settles, because those paths retain the full +/// resemblance set for observations. +fn resemblance_work_units( + proposals: &[ProposedVoucher], + parties: &[PartyResolution], + index: &WindowIndex<'_>, +) -> Result { + let mut total = 0usize; + for (proposal, party) in proposals.iter().zip(parties) { + let date_posts = index.by_date.get(proposal.date()).map_or(0, Vec::len); + let party_posts = party.compare_keys.iter().try_fold(0usize, |sum, key| { + sum.checked_add(index.by_ledger.get(key.as_str()).map_or(0, Vec::len)) + .ok_or(PresenceError::ComparisonWorkTooLarge) + })?; + let pool_upper = date_posts + .checked_add(party_posts) + .ok_or(PresenceError::ComparisonWorkTooLarge)?; + let party_checks = pool_upper + .checked_mul(party.compare_keys.len()) + .ok_or(PresenceError::ComparisonWorkTooLarge)?; + let units = 1usize + .checked_add(date_posts) + .and_then(|n| n.checked_add(party_posts)) + .and_then(|n| n.checked_add(pool_upper)) + .and_then(|n| n.checked_add(party_checks)) + .ok_or(PresenceError::ComparisonWorkTooLarge)?; + total = total + .checked_add(units) + .ok_or(PresenceError::ComparisonWorkTooLarge)?; + } + Ok(total) +} + +/// Decides every proposal against the window. +/// +/// `Present` requires identity unique on both sides. `Absent` requires that no +/// rule produced any candidate. Everything between is `PossiblyPresent` and is +/// never resolved here. See `docs/adr/0017-voucher-presence-authority.md` for +/// why the two bars are set at different heights. +pub fn assess(request: &PresenceRequest<'_>) -> PresenceReport { + let window = request.window; + let index = WindowIndex::build(window); + + let mut proposal_remote_counts: BTreeMap<&str, usize> = BTreeMap::new(); + let mut proposal_number_counts: BTreeMap<(&str, &str), usize> = BTreeMap::new(); + for proposal in request.proposals { + if let Some(remote_id) = proposal.remote_id.as_deref() { + *proposal_remote_counts.entry(remote_id).or_default() += 1; + } + if let Some(number_key) = proposal.number_key.as_deref() { + *proposal_number_counts + .entry((proposal.type_key.as_str(), number_key)) + .or_default() += 1; + } + } + + let mut touched_book: BTreeSet = BTreeSet::new(); + let mut proposed_type_keys: BTreeSet<&str> = BTreeSet::new(); + let mut vouchers = Vec::with_capacity(request.proposals.len()); + for (proposal, party) in request.proposals.iter().zip(&request.party_bindings) { + proposed_type_keys.insert(proposal.type_key.as_str()); + let decided = decide( + proposal, + party, + window, + &index, + request.numbering, + &proposal_remote_counts, + &proposal_number_counts, + ); + touched_book.extend(decided.touched); + vouchers.push(decided.presence); + } + + // One book voucher satisfies at most one proposal. Uniqueness was enforced + // within each identity basis; nothing yet stopped two proposals reaching + // the same voucher by *different* bases — one by `REMOTEID`, another by a + // manual number — and a consumer would then exclude two source vouchers + // against one book row, silently dropping an invoice. Every claimant is + // demoted; choosing between them would be the auto-resolution this whole + // contract refuses. + let mut claims: BTreeMap = BTreeMap::new(); + for entry in &vouchers { + if let Some(book_key) = entry.present_book_key() { + *claims.entry(book_key.to_string()).or_default() += 1; + } + } + let contested = claims + .into_iter() + .filter(|(_, count)| *count > 1) + .map(|(book_key, _)| book_key) + .collect::>(); + if !contested.is_empty() { + for entry in &mut vouchers { + let Some(book_key) = entry.present_book_key() else { + continue; + }; + if !contested.contains(book_key) { + continue; + } + let book_key = book_key.to_string(); + let rule = match &entry.status { + PresenceStatus::Present { + basis: PresenceBasis::RemoteId, + .. + } => CandidateRule::SharedRemoteId, + _ => CandidateRule::SharedVoucherNumber, + }; + entry.status = PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::BookVoucherClaimedTwice, + (vec![PresenceCandidate { book_key, rule }], 1), + )); + } + } + + let observations = observe(window, &index, &proposed_type_keys, &touched_book); + PresenceReport { + window_from: window.from().to_string(), + window_to: window.to().to_string(), + vouchers, + observations, + } +} + +/// One proposal's verdict, plus every book voucher it reached *before* the +/// response candidate cap. The observations need the full set: a candidate +/// dropped by the cap was still resembled, and counting it as untouched would +/// report it as a voucher no proposal came near. +struct Decided { + presence: VoucherPresence, + touched: BTreeSet, +} + +#[allow(clippy::too_many_arguments)] +fn decide( + proposal: &ProposedVoucher, + party: &PartyResolution, + window: &BookWindow, + index: &WindowIndex<'_>, + numbering: &NumberingDeclaration, + proposal_remote_counts: &BTreeMap<&str, usize>, + proposal_number_counts: &BTreeMap<(&str, &str), usize>, +) -> Decided { + let method = numbering + .method(&proposal.type_key) + .expect("PresenceRequest refused an undeclared numbering method"); + let type_observed = index.type_keys.contains(proposal.type_key.as_str()); + let shell = |status: PresenceStatus, touched: BTreeSet| Decided { + presence: VoucherPresence { + position: proposal.position, + voucher_number: proposal.voucher_number.clone(), + numbering_method: method, + voucher_type_observed: type_observed, + party: party.outcome.clone(), + status, + }, + touched, + }; + // A proposal carrying a `REMOTEID` the window never fetched has had its + // strongest key silently skipped. That cannot license an absence. + let remote_id_unverifiable = + proposal.remote_id.is_some() && window.remote_id_evidence() == RemoteIdEvidence::NotRead; + + // Both identity lookups are resolved *before* either settles, so that a + // `REMOTEID` selecting one voucher while the number selects another can be + // reported as a disagreement instead of decided by whichever ran first. + // That is why the number lookup sits above rule one rather than under + // rule two, where it is used. + let number_matches: Vec = proposal + .number_key + .as_deref() + .map(|number_key| { + if type_observed { + index + .by_type_and_number + .get(&(proposal.type_key.as_str(), number_key)) + .cloned() + .unwrap_or_default() + } else { + // The type name was never observed, so it discriminates + // nothing. Widen rather than manufacture an absence. + index.by_number.get(number_key).cloned().unwrap_or_default() + } + }) + .unwrap_or_default(); + + // A verdict decided before rule three — an identity match as much as a + // collision — still *reached* whatever it resembles, and the observations + // count only what no proposal came near. + let with_resemblances = |touched: BTreeSet| { + let mut touched = touched; + touched.extend(resemblances(proposal, party, window, index, &number_matches).into_keys()); + 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()), + ); + } + 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]])), + ); + } + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::RemoteIdCollision, + candidates_from(window, matches, CandidateRule::SharedRemoteId), + )), + with_resemblances(matches.iter().copied().collect()), + ); + } + } + + // Rule two: a voucher number is identity only where the numbering method + // preserves it (§9.8), and only when it is unique on both sides. + // + // The proposal side comes first, because a collision between two proposals + // is a fact about the *source*: it does not become less true because the + // book has never seen this voucher type. + if method == NumberingMethod::Manual { + if let Some(number_key) = proposal.number_key.as_deref() { + let proposed_twice = proposal_number_counts + .get(&(proposal.type_key.as_str(), number_key)) + .copied() + .unwrap_or_default() + > 1; + if proposed_twice { + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::ProposalNumberCollision, + candidates_from( + window, + &number_matches, + CandidateRule::SharedVoucherNumber, + ), + )), + with_resemblances(number_matches.iter().copied().collect()), + ); + } + } + } + + // 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 { + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::BookNumberCollision, + candidates_from(window, &number_matches, CandidateRule::SharedVoucherNumber), + )), + touched, + ); + } + if number_matches.len() == 1 { + let matched = &window.vouchers[number_matches[0]]; + // Two identity signals that disagree are reported, never + // settled in the number's favour — the same rule ADR 0016 + // applies to an identifier contradicting an exact name. + let contradicted = match (proposal.remote_id.as_deref(), matched.remote_id.as_deref()) { + (Some(proposed), 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, + ); + } + return shell( + settled(proposal, party, matched, PresenceBasis::ManualVoucherNumber), + touched, + ); + } + } + + // Rule three: everything else is resemblance, and resemblance decides + // nothing. It only widens what a person is asked to look at. + let found = resemblances(proposal, party, window, index, &number_matches); + + 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 { + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::RemoteIdEvidenceUnavailable, + (Vec::new(), 0), + )), + BTreeSet::new(), + ); + } + // Under a Manual declaration the number is the deciding key. A + // proposal that supplies none has offered nothing decisive, so an + // absence would rest on date, party and amount — which this contract + // does not let decide. + if method == NumberingMethod::Manual && proposal.number_key.is_none() { + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::ManualNumberNotSupplied, + (Vec::new(), 0), + )), + BTreeSet::new(), + ); + } + if party.incomplete { + let reason = match party.outcome { + PartyOutcome::NotSupplied => UndecidedReason::PartyNotSupplied, + _ => UndecidedReason::PartyNotDecidable, + }; + return shell( + PresenceStatus::PossiblyPresent(undecided(reason, (Vec::new(), 0))), + BTreeSet::new(), + ); + } + // Every other decisive key was either absent or already compared — + // but `Absent` claims "not anywhere in this window", and that claim + // is only sound when the window's own read covered its whole range. + if window.read() != WindowRead::Complete { + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::WindowNotProvenComplete, + (Vec::new(), 0), + )), + BTreeSet::new(), + ); + } + return shell(PresenceStatus::Absent, BTreeSet::new()); + } + + let touched = found.keys().copied().collect::>(); + let reason = if remote_id_unverifiable { + UndecidedReason::RemoteIdEvidenceUnavailable + } else { + match ( + number_matches.is_empty(), + type_observed, + method == NumberingMethod::Manual, + ) { + (false, false, _) => UndecidedReason::VoucherTypeNotObserved, + (false, true, false) => UndecidedReason::NumberNotDecisive, + _ => UndecidedReason::ResemblesBookVoucher, + } + }; + // Ordered as (position, rule) pairs before anything is cloned: the order is + // rule-then-key and only the retained prefix needs a key at all. + let mut ordered = found.into_iter().collect::>(); + let ranked = candidates_ranked(window, &mut ordered); + shell( + PresenceStatus::PossiblyPresent(undecided(reason, ranked)), + touched, + ) +} + +/// Every book voucher this proposal resembles, strongest rule per voucher. +/// +/// Extracted because the *touched* set it produces is needed even on paths that +/// return before resemblance can decide anything. A collision returns early +/// with only its colliding positions, and `unmatched_book_vouchers` promises to +/// count rows no proposal "matched or even resembled" — so a row this proposal +/// plainly resembles must not be counted there merely because a collision +/// outranked the resemblance. The scan is indexed, and the paths that need it +/// early are collisions, which are rare. +fn resemblances( + proposal: &ProposedVoucher, + party: &PartyResolution, + window: &BookWindow, + index: &WindowIndex<'_>, + number_matches: &[usize], +) -> BTreeMap { + let mut found: BTreeMap = BTreeMap::new(); + for position in number_matches { + keep_strongest(&mut found, *position, CandidateRule::SharedVoucherNumber); + } + let mut pool: BTreeSet = BTreeSet::new(); + if let Some(positions) = index.by_date.get(proposal.date()) { + pool.extend(positions.iter().copied()); + } + for key in &party.compare_keys { + if let Some(positions) = index.by_ledger.get(key.as_str()) { + pool.extend(positions.iter().copied()); + } + } + for position in pool { + let voucher = &window.vouchers[position]; + let same_date = voucher.date() == proposal.date(); + let same_amount = voucher.magnitude.numeric_eq(&proposal.magnitude); + let same_party = party + .compare_keys + .iter() + .any(|key| voucher.ledger_keys.contains(key)); + let rule = match (same_date, same_party, same_amount) { + (true, true, true) => CandidateRule::SameDatePartyAmount, + (_, true, true) => CandidateRule::SamePartyAmount, + (true, false, true) => CandidateRule::SameDateAmount, + (true, true, false) => CandidateRule::SameDateParty, + _ => continue, + }; + keep_strongest(&mut found, position, rule); + } + found +} + +/// Turns an identity match into a status. A cancelled or optional voucher +/// occupies the number without being posted, so it is never `Present`. +fn settled( + proposal: &ProposedVoucher, + party: &PartyResolution, + voucher: &BookVoucher, + basis: PresenceBasis, +) -> PresenceStatus { + if voucher.posting != PostingState::Posted { + return PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::MatchedVoucherNotPosted, + ( + vec![PresenceCandidate { + book_key: voucher.key().to_string(), + rule: match basis { + PresenceBasis::RemoteId => CandidateRule::SharedRemoteId, + PresenceBasis::ManualVoucherNumber => CandidateRule::SharedVoucherNumber, + }, + }], + 1, + ), + )); + } + PresenceStatus::Present { + book_key: voucher.key().to_string(), + basis, + differences: differences(proposal, party, voucher), + } +} + +/// What an identified voucher disagrees with its source about. One engagement +/// found an invoice posted short by exactly one dropped GST head this way. +fn differences( + proposal: &ProposedVoucher, + party: &PartyResolution, + voucher: &BookVoucher, +) -> Vec { + let mut differences = Vec::new(); + if voucher.voucher_type != proposal.voucher_type { + differences.push(Difference { + field: DifferenceField::VoucherType, + proposed: Some(proposal.voucher_type.clone()), + observed: Some(voucher.voucher_type.clone()), + }); + } + if voucher.date() != proposal.date() { + differences.push(Difference { + field: DifferenceField::Date, + proposed: Some(proposal.date().to_string()), + observed: Some(voucher.date().to_string()), + }); + } + if !voucher.magnitude.numeric_eq(&proposal.magnitude) { + differences.push(Difference { + field: DifferenceField::Amount, + proposed: Some(proposal.magnitude.as_str().to_string()), + observed: Some(voucher.magnitude.as_str().to_string()), + }); + } + // Two narrowings, and each has a reason the other does not. + // + // Only a *bound* party can disagree: an ambiguous one has no single name to + // disagree with, and asserting a difference against a candidate would be + // the same guess by another route. + // + // And the comparison is against the *observed party field*, not against + // every ledger the voucher touches. Widening to all entry ledgers is right + // for finding a candidate and wrong for reporting a disagreement — a + // voucher whose party is one name while an entry names another would + // otherwise report no difference while serializing the other name as + // `observed`. A voucher with no party field has nothing to disagree with. + if let (PartyOutcome::Bound { catalog_name }, Some(observed)) = + (&party.outcome, voucher.party.as_deref()) + { + if comparison_key(observed) != comparison_key(catalog_name) { + differences.push(Difference { + field: DifferenceField::Party, + // Bounded for the same reason the observation labels are: a + // response can drop whole rows but cannot shrink one, and the + // comparison above already used the full values. + proposed: proposal.party.as_deref().map(label), + observed: Some(label(observed)), + }); + } + } + differences +} + +fn observe( + window: &BookWindow, + index: &WindowIndex<'_>, + proposed_type_keys: &BTreeSet<&str>, + touched: &BTreeSet, +) -> BookObservations { + let mut duplicate_numbers = Vec::new(); + let mut duplicate_number_group_count = 0_usize; + for ((_, _), positions) in &index.by_type_and_number { + if positions.len() < 2 { + continue; + } + duplicate_number_group_count += 1; + if duplicate_numbers.len() >= MAX_DUPLICATE_NUMBER_GROUPS { + continue; + } + let mut ordered = positions.to_vec(); + ordered.sort_by(|left, right| { + window.vouchers[*left] + .key() + .cmp(window.vouchers[*right].key()) + }); + let first = &window.vouchers[ordered[0]]; + duplicate_numbers.push(DuplicateNumberGroup { + voucher_type: label(&first.voucher_type), + voucher_number: first + .voucher_number + .as_deref() + .map(label) + .unwrap_or_default(), + book_keys: ordered + .iter() + .take(MAX_KEYS_PER_DUPLICATE_GROUP) + .map(|position| window.vouchers[*position].key().to_string()) + .collect(), + book_voucher_count: positions.len(), + }); + } + + let mut unbalanced: Vec<&BookVoucher> = window + .vouchers + .iter() + .filter(|voucher| !voucher.balanced()) + .collect(); + unbalanced.sort_by(|left, right| left.key().cmp(right.key())); + + let unmatched_book_vouchers = window + .vouchers + .iter() + .enumerate() + .filter(|(position, voucher)| { + proposed_type_keys.contains(voucher.type_key.as_str()) && !touched.contains(position) + }) + .count(); + + BookObservations { + duplicate_numbers, + duplicate_number_group_count, + duplicate_numbers_truncated: duplicate_number_group_count > MAX_DUPLICATE_NUMBER_GROUPS, + unbalanced_vouchers: unbalanced + .iter() + .take(MAX_UNBALANCED_LISTED) + .map(|voucher| voucher.key().to_string()) + .collect(), + unbalanced_voucher_count: unbalanced.len(), + unmatched_book_vouchers, + window_voucher_count: window.vouchers.len(), + remote_id_observed: !index.by_remote_id.is_empty(), + } +} + +/// Bounds an echoed observation label. See `MAX_OBSERVATION_LABEL_CHARS`. +/// Bounds a value echoed back to the caller, and says so when it shortened one. +/// +/// The marker is not decoration. Every comparison upstream runs on the *full* +/// values, so two names differing only past the bound would otherwise serialize +/// as one identical pair sitting beside a claim that they differ. The marker +/// does not recover the distinction -- nothing at this bound can -- but it +/// keeps the report from asserting something false about what it is showing. +/// +/// It is appended *outside* `MAX_OBSERVATION_LABEL_CHARS` rather than taking a +/// character of content to make room, and that is deliberate. Spending a +/// character would make two values differing at exactly the bound serialize +/// identically -- turning a difference that was visible before this marker +/// existed into one that is not, which is the failure the marker is here to +/// prevent, reintroduced one position earlier. The constant bounds the echoed +/// *value*; one character of annotation on top of it bounds nothing worth +/// bounding. +fn label(value: &str) -> String { + if value.chars().count() <= MAX_OBSERVATION_LABEL_CHARS { + return value.to_string(); + } + let mut bounded: String = value.chars().take(MAX_OBSERVATION_LABEL_CHARS).collect(); + bounded.push(SHORTENED); + bounded +} + +/// The key a *voucher number* is compared on, which is deliberately narrower +/// than the one master names use. +/// +/// `comparison_key` folds case and unifies dash and quote variants, and that +/// fold is not arbitrary: §3.3b measured Tally's own master-name matching and +/// the key follows it. **No such measurement exists for voucher numbers.** +/// Applying the name fold to them was an assumption wearing a measurement's +/// clothes, and it fails in the silent direction: folding produces *more* +/// matches, a wrong match on a number is a `Present`, and a `Present` tells a +/// caller an invoice is already filed. Two distinct invoices numbered `a-1` +/// and `A-1` would have suppressed one another. +/// +/// Only outer whitespace is a transport artefact in the current adapter. +/// Unicode composition, internal whitespace, case and punctuation are content +/// until something measures otherwise. This narrows toward the noisy failure: +/// an unmatched variant costs a duplicate a person can see. +fn number_key_of(value: &str) -> String { + value.trim().to_string() +} + +fn keep_strongest( + found: &mut BTreeMap, + position: usize, + rule: CandidateRule, +) { + found + .entry(position) + .and_modify(|held| { + if rule.rank() < held.rank() { + *held = rule; + } + }) + .or_insert(rule); +} + +/// Builds the *retained* candidates, ordered, and reports how many there were. +/// +/// Two properties, and the second is why this is one function rather than +/// three call sites. **Ordering is part of the contract** — rule, then book key +/// — so a dense collision exposes the same subset however Tally happened to +/// order its rows, and a reviewer comparing two runs of an unchanged book does +/// not see a different twenty-five. And the cap is applied **before** the +/// clone: a window can hold thousands of vouchers on one number, and cloning +/// them all to discard all but twenty-five is millions of allocations for a +/// bounded answer. Sorting `(position, rule)` pairs allocates nothing. +fn candidates_ranked( + window: &BookWindow, + entries: &mut [(usize, CandidateRule)], +) -> (Vec, usize) { + entries.sort_by(|(left, left_rule), (right, right_rule)| { + left_rule.rank().cmp(&right_rule.rank()).then_with(|| { + window.vouchers[*left] + .key() + .cmp(window.vouchers[*right].key()) + }) + }); + let found = entries.len(); + let retained = entries + .iter() + .take(MAX_CANDIDATES_PER_PROPOSAL) + .map(|(position, rule)| PresenceCandidate { + book_key: window.vouchers[*position].key().to_string(), + rule: *rule, + }) + .collect(); + (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), +) -> Undecided { + Undecided { + reason, + candidates_truncated: candidates.len() < found, + candidates, + candidate_count: found, + } +} + +/// One definition of a voucher's magnitude, used by both sides so the two can +/// never compute it differently. It is the sum of the positive entry amounts, +/// which is defined whether or not the voucher balances. +fn magnitude_of( + entries: &[ObservedEntry<'_>], +) -> Result<(ExactDecimal, bool, BTreeSet, BTreeSet), PresenceError> { + if entries.is_empty() { + return Err(PresenceError::EntriesEmpty); + } + if entries.len() > MAX_ENTRIES_PER_VOUCHER { + return Err(PresenceError::TooManyEntries); + } + let mut total = ExactDecimalAccumulator::default(); + let mut positive = ExactDecimalAccumulator::default(); + let mut observed_ledgers = BTreeSet::new(); + let mut ledger_keys = BTreeSet::new(); + for entry in entries { + let amount = ExactDecimal::parse(entry.amount.to_string()) + .map_err(|_| PresenceError::AmountInvalid)?; + total.add(amount.as_str()); + if !amount.is_negative() { + positive.add(amount.as_str()); + } + let ledger = validated_text(entry.ledger)?; + observed_ledgers.insert(ledger.clone()); + ledger_keys.insert(comparison_key(&ledger)); + } + let magnitude = ExactDecimal::parse(positive.canonical_string()) + .map_err(|_| PresenceError::AmountInvalid)?; + Ok((magnitude, total.is_zero(), observed_ledgers, ledger_keys)) +} + +fn validated_text(value: &str) -> Result { + if value.trim().is_empty() { + return Err(PresenceError::TextBlank); + } + if value.chars().count() > MAX_TEXT_CHARS { + return Err(PresenceError::TextTooLong); + } + if value.chars().any(|character| { + character.is_control() || matches!(character, '\u{2028}' | '\u{2029}' | '\u{feff}') + }) { + return Err(PresenceError::TextUnsafe); + } + Ok(value.to_string()) +} + +#[cfg(test)] +#[path = "book_presence_tests.rs"] +mod tests; diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs new file mode 100644 index 00000000..edd9409b --- /dev/null +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -0,0 +1,3610 @@ +//! Every name, number and amount here is fabricated from a placeholder +//! alphabet. These tests establish the behaviour of the rules; they are not, +//! and may not be presented as, evidence about any Tally instance. + +use super::*; + +const LEDGERS: [&str; 6] = [ + "Alpha Traders", + "Bravo Industries", + "Charlie Minerals", + "Sales Account", + "Output CGST 9%", + "Output SGST 9%", +]; + +fn catalog() -> MasterCatalog { + MasterCatalog::new(MasterClass::Ledger, LEDGERS).expect("catalog") +} + +fn catalog_of(names: &[&str]) -> MasterCatalog { + MasterCatalog::new(MasterClass::Ledger, names).expect("catalog") +} + +fn entries<'a>(rows: &'a [[&'a str; 2]]) -> Vec> { + rows.iter() + .map(|row| ObservedEntry { + ledger: row[0], + amount: row[1], + }) + .collect() +} + +struct BookRow { + key: &'static str, + date: &'static str, + voucher_type: &'static str, + number: Option<&'static str>, + remote_id: Option<&'static str>, + party: Option<&'static str>, + rows: Vec<[&'static str; 2]>, + cancelled: bool, + optional: bool, +} + +impl BookRow { + fn new(key: &'static str, date: &'static str, number: &'static str) -> Self { + Self { + key, + date, + voucher_type: "Sales", + number: Some(number), + remote_id: None, + party: Some("Alpha Traders"), + rows: vec![ + ["Alpha Traders", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ["Output SGST 9%", "900.00"], + ], + cancelled: false, + optional: false, + } + } + + fn party(mut self, party: &'static str) -> Self { + self.rows[0][0] = party; + self.party = Some(party); + self + } + + fn voucher_type(mut self, voucher_type: &'static str) -> Self { + self.voucher_type = voucher_type; + self + } + + fn remote_id(mut self, remote_id: &'static str) -> Self { + self.remote_id = Some(remote_id); + self + } + + fn rows(mut self, rows: Vec<[&'static str; 2]>) -> Self { + self.rows = rows; + self + } + + /// Sets PARTYLEDGERNAME alone, leaving the entry ledgers untouched, so a + /// voucher whose party field and entries name different ledgers can exist. + fn party_field(mut self, party: &'static str) -> Self { + self.party = Some(party); + self + } + + fn cancelled(mut self) -> Self { + self.cancelled = true; + self + } + + fn optional(mut self) -> Self { + self.optional = true; + self + } + + fn build(&self) -> BookVoucher { + let entries = entries(&self.rows); + BookVoucher::observed(ObservedVoucher { + key: self.key, + date: self.date, + voucher_type: self.voucher_type, + voucher_number: self.number, + remote_id: self.remote_id, + party: self.party, + entries: &entries, + cancelled: self.cancelled, + optional: self.optional, + }) + .expect("observed voucher") + } +} + +struct ProposalRow { + position: usize, + date: &'static str, + voucher_type: &'static str, + number: Option<&'static str>, + remote_id: Option<&'static str>, + party: Option<&'static str>, + rows: Vec<[&'static str; 2]>, +} + +impl ProposalRow { + fn new(position: usize, date: &'static str, number: &'static str) -> Self { + Self { + position, + date, + voucher_type: "Sales", + number: Some(number), + remote_id: None, + party: Some("Alpha Traders"), + rows: vec![ + ["Alpha Traders", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ["Output SGST 9%", "900.00"], + ], + } + } + + fn party(mut self, party: &'static str) -> Self { + self.rows[0][0] = party; + self.party = Some(party); + self + } + + fn voucher_type(mut self, voucher_type: &'static str) -> Self { + self.voucher_type = voucher_type; + self + } + + fn remote_id(mut self, remote_id: &'static str) -> Self { + self.remote_id = Some(remote_id); + self + } + + fn rows(mut self, rows: Vec<[&'static str; 2]>) -> Self { + self.rows = rows; + self + } + + fn build(&self) -> ProposedVoucher { + let entries = entries(&self.rows); + ProposedVoucher::new(ProposedVoucherInput { + position: self.position, + date: self.date, + voucher_type: self.voucher_type, + voucher_number: self.number, + remote_id: self.remote_id, + party: self.party, + entries: &entries, + }) + .expect("proposed voucher") + } +} + +fn window(rows: &[BookRow]) -> BookWindow { + BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + rows.iter().map(BookRow::build).collect(), + ) + .expect("window") +} + +fn numbering(method: NumberingMethod) -> NumberingDeclaration { + NumberingDeclaration::new([("Sales", method)]).expect("numbering") +} + +fn run( + window: &BookWindow, + catalog: &MasterCatalog, + numbering: &NumberingDeclaration, + proposals: &[ProposedVoucher], +) -> PresenceReport { + // These rule tests name only the ledgers relevant to their assertion. The + // public boundary now requires a complete catalog, so complete that test + // fixture from the already-observed window rather than weakening the + // boundary every test reaches through this helper. + let mut names = catalog.names().map(str::to_owned).collect::>(); + names.extend( + window + .vouchers() + .iter() + .flat_map(|voucher| voucher.observed_ledgers.iter().cloned()), + ); + names.sort(); + names.dedup(); + let complete_catalog = + MasterCatalog::new(MasterClass::Ledger, &names).expect("complete catalog"); + let request = + PresenceRequest::new(window, &complete_catalog, numbering, proposals).expect("request"); + assess(&request) +} + +fn only(report: &PresenceReport) -> &VoucherPresence { + assert_eq!(report.vouchers().len(), 1); + &report.vouchers()[0] +} + +fn reason(entry: &VoucherPresence) -> UndecidedReason { + entry.undecided().expect("undecided").reason +} + +// --- the window is a claim about a window ------------------------------ + +/// A window's completeness gate moved from construction to verdict +/// production (see `PresenceStatus::Absent`'s doc comment and `decide`): a +/// `Partial` read is still a legal `BookWindow`, and it retains its own +/// `read()` rather than having it checked once and discarded, because +/// `decide` needs it every time it would otherwise settle `Absent`. +#[test] +fn a_partial_read_can_become_a_window_that_remembers_it_was_partial() { + let window = BookWindow::observed( + "20260801", + "20260831", + WindowRead::Partial, + RemoteIdEvidence::Observed, + Vec::new(), + ) + .expect("a partial read is still a window"); + assert_eq!(window.read(), WindowRead::Partial); +} + +#[test] +fn an_empty_complete_window_is_legal_and_reports_everything_absent() { + let window = window(&[]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert!(only(&report).is_absent()); + assert_eq!(report.totals().absent, 1); +} + +#[test] +fn a_window_refuses_a_voucher_dated_outside_its_own_range() { + let outside = BookRow::new("book-1", "20260901", "AA0118").build(); + assert_eq!( + BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + vec![outside] + ) + .expect_err("outside"), + PresenceError::WindowVoucherOutsideRange + ); +} + +#[test] +fn a_window_refuses_the_same_voucher_key_twice() { + let rows = vec![ + BookRow::new("book-1", "20260812", "AA0118").build(), + BookRow::new("book-1", "20260813", "AA0119").build(), + ]; + assert_eq!( + BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + rows + ) + .expect_err("duplicate"), + PresenceError::WindowDuplicateVoucherKey + ); +} + +#[test] +fn a_window_refuses_an_inverted_range() { + assert_eq!( + BookWindow::observed( + "20260831", + "20260801", + WindowRead::Complete, + RemoteIdEvidence::Observed, + Vec::new() + ) + .expect_err("inverted"), + PresenceError::WindowRangeInvalid + ); +} + +#[test] +fn a_proposal_outside_the_window_is_refused_rather_than_judged() { + let window = window(&[]); + let proposals = [ProposalRow::new(0, "20260902", "AA0118").build()]; + assert_eq!( + PresenceRequest::new( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals + ) + .expect_err("uncovered"), + PresenceError::WindowDoesNotCover + ); +} + +#[test] +fn every_verdict_is_scoped_to_the_window_it_names() { + let window = window(&[]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(report.window(), ("20260801", "20260831")); +} + +// --- the numbering method is declared ---------------------------------- + +#[test] +fn an_undeclared_numbering_method_is_an_error_not_a_default() { + let window = window(&[]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .voucher_type("Part and Labour Sale") + .build()]; + assert_eq!( + PresenceRequest::new( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals + ) + .expect_err("undeclared"), + PresenceError::NumberingMethodUndeclared + ); +} + +#[test] +fn differently_spelled_voucher_types_have_independent_declarations() { + let declaration = NumberingDeclaration::new([ + ("Sales", NumberingMethod::Manual), + ("sales", NumberingMethod::Automatic), + ]) + .expect("distinct exact type names"); + assert!(declaration.declares("Sales")); + assert!(declaration.declares("sales")); + assert!(!declaration.declares(" SALES ")); +} + +#[test] +fn conflicting_declarations_of_the_same_exact_voucher_type_are_refused() { + assert_eq!( + NumberingDeclaration::new([ + ("Sales", NumberingMethod::Manual), + ("Sales", NumberingMethod::Automatic), + ]) + .expect_err("conflict"), + PresenceError::NumberingMethodConflict + ); +} + +#[test] +fn a_repeated_identical_declaration_is_accepted() { + assert!(NumberingDeclaration::new([ + ("Sales", NumberingMethod::Manual), + ("Sales", NumberingMethod::Manual), + ]) + .is_ok()); +} + +#[test] +fn numbering_declarations_bound_duplicate_iterator_work() { + let entries = (0..=MAX_NUMBERING_DECLARATIONS).map(|_| ("Sales", NumberingMethod::Manual)); + assert_eq!( + NumberingDeclaration::new(entries).expect_err("declaration count is bounded"), + PresenceError::NumberingDeclarationsTooMany + ); +} + +#[test] +fn numbering_declarations_bound_aggregate_bytes_while_consuming_duplicates() { + let entries = (0..).map(|_| ("X".repeat(MAX_TEXT_CHARS), NumberingMethod::Manual)); + assert_eq!( + NumberingDeclaration::new(entries).expect_err("declaration bytes are bounded"), + PresenceError::NumberingDeclarationBytesTooLarge + ); +} + +#[test] +fn admitted_indexed_work_boundary_is_accepted() { + let rows = (0..500) + .map(|i| { + BookRow::new( + Box::leak(format!("book-{i}").into_boxed_str()), + "20260812", + Box::leak(format!("N{i}").into_boxed_str()), + ) + }) + .collect::>(); + let proposals = (0..500) + .map(|i| { + ProposalRow::new(i, "20260812", Box::leak(format!("P{i}").into_boxed_str())).build() + }) + .collect::>(); + let observed = window(&rows); + assert!(PresenceRequest::new( + &observed, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ) + .is_ok()); +} + +#[test] +fn request_refuses_a_window_ledger_missing_from_its_catalog() { + let window = + window(&[BookRow::new("book-1", "20260812", "AA0118") + .rows(vec![["Uncatalogued Ledger", "0.00"]])]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + assert_eq!( + PresenceRequest::new( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals + ) + .expect_err("window ledger is absent from catalog"), + PresenceError::CatalogWindowCoverageMissing + ); +} + +#[test] +fn request_coverage_uses_the_exact_observed_spelling() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118") + .party_field("Café") + .rows(vec![["Café", "0.00"]])]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let exact = catalog_of(&["Café"]); + assert!(PresenceRequest::new( + &window, + &exact, + &numbering(NumberingMethod::Manual), + &proposals + ) + .is_ok()); + let folded_only = catalog_of(&["café"]); + assert_eq!( + PresenceRequest::new( + &window, + &folded_only, + &numbering(NumberingMethod::Manual), + &proposals + ) + .expect_err("folded spelling is not exact coverage"), + PresenceError::CatalogWindowCoverageMissing + ); +} + +// --- only identity produces Present ------------------------------------ + +#[test] +fn a_manual_voucher_number_unique_on_both_sides_decides_presence() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert_eq!(entry.present_book_key(), Some("book-1")); + assert!(matches!( + entry.status, + PresenceStatus::Present { + basis: PresenceBasis::ManualVoucherNumber, + .. + } + )); +} + +#[test] +fn a_voucher_number_decides_nothing_under_automatic_numbering() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Automatic), + &proposals, + ); + let entry = only(&report); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::NumberNotDecisive); +} + +#[test] +fn a_voucher_number_decides_nothing_under_unknown_numbering() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Unknown), + &proposals, + ); + assert_eq!(reason(only(&report)), UndecidedReason::NumberNotDecisive); +} + +/// This asserted the opposite until a review asked what measured it. +/// +/// A voucher number used the master-name key, so `aa-0118` matched `AA-0118` +/// and settled `Present`. The fold that key applies is not arbitrary -- §3.3b +/// measured Tally's own master-name matching and the key follows it -- but +/// nothing measured it for *numbers*, and borrowing the conclusion without the +/// measurement is how an assumption acquires a citation. +/// +/// It also fails in the wrong direction. Folding produces more matches, a +/// wrong number match is a `Present`, and a `Present` tells a caller the +/// invoice is already filed. Two distinct invoices numbered `aa-0118` and +/// `AA-0118` would each have suppressed the other. +#[test] +fn a_voucher_number_is_not_folded_the_way_a_master_name_is() { + let window = window(&[BookRow::new("book-1", "20260812", "aa-0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA-0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!( + only(&report).present_book_key(), + None, + "case is content in a number until a measurement says otherwise" + ); +} + +#[test] +fn a_number_carried_by_two_book_vouchers_decides_nothing() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118"), + BookRow::new("book-2", "20260814", "AA0118").party("Bravo Industries"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert_eq!(reason(entry), UndecidedReason::BookNumberCollision); + assert_eq!(entry.undecided().expect("undecided").candidates.len(), 2); +} + +#[test] +fn a_number_claimed_by_two_proposals_decides_nothing() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ + ProposalRow::new(0, "20260812", "AA0118").build(), + ProposalRow::new(1, "20260813", "AA0118") + .party("Bravo Industries") + .build(), + ]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + for entry in report.vouchers() { + assert_eq!(reason(entry), UndecidedReason::ProposalNumberCollision); + } +} + +#[test] +fn internal_whitespace_in_a_voucher_number_is_content() { + let window = window(&[BookRow::new("book-1", "20260812", "AA 0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA 0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_ne!(only(&report).present_book_key(), Some("book-1")); +} + +#[test] +fn a_remote_id_unique_on_both_sides_decides_presence() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").remote_id("bridge-txn-1")]); + let proposals = [ProposalRow::new(0, "20260814", "AA9999") + .remote_id("bridge-txn-1") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Automatic), + &proposals, + ); + assert!(matches!( + only(&report).status, + PresenceStatus::Present { + basis: PresenceBasis::RemoteId, + .. + } + )); +} + +#[test] +fn a_remote_id_on_two_book_vouchers_decides_nothing() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118").remote_id("bridge-txn-1"), + BookRow::new("book-2", "20260813", "AA0119").remote_id("bridge-txn-1"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .remote_id("bridge-txn-1") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(reason(only(&report)), UndecidedReason::RemoteIdCollision); +} + +#[test] +fn an_absent_remote_id_column_is_reported_so_no_match_is_not_read_as_evidence() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert!(!report.observations().remote_id_observed); +} + +#[test] +fn a_manual_number_never_decides_across_an_unobserved_voucher_type() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").voucher_type("Parts Sale")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .voucher_type("Part Sale") + .build()]; + let declaration = + NumberingDeclaration::new([("Part Sale", NumberingMethod::Manual)]).expect("numbering"); + let report = run(&window, &catalog(), &declaration, &proposals); + let entry = only(&report); + assert!(!entry.voucher_type_observed); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::VoucherTypeNotObserved); + assert_eq!(entry.undecided().expect("undecided").candidates.len(), 1); +} + +// --- cancelled and optional vouchers ----------------------------------- + +#[test] +fn an_identity_match_on_a_cancelled_voucher_is_never_present() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").cancelled()]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::MatchedVoucherNotPosted); +} + +#[test] +fn an_identity_match_on_an_optional_voucher_is_never_present() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").optional()]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!( + reason(only(&report)), + UndecidedReason::MatchedVoucherNotPosted + ); +} + +// --- Present reports what disagrees ------------------------------------ + +#[test] +fn a_present_voucher_reports_an_amount_the_book_posted_short() { + // One 9% head dropped when the voucher was keyed by hand. + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").rows(vec![ + ["Alpha Traders", "-10900.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ])]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + assert_eq!(differences.len(), 1); + assert_eq!(differences[0].field, DifferenceField::Amount); + // Magnitudes are reported in canonical exact-decimal form, so a scale-only + // difference between two readings of one amount is never a difference. + assert_eq!(differences[0].proposed.as_deref(), Some("11800")); + assert_eq!(differences[0].observed.as_deref(), Some("10900")); +} + +#[test] +fn a_present_voucher_reports_a_date_the_book_disagrees_with() { + let window = window(&[BookRow::new("book-1", "20260825", "AA0309")]); + let proposals = [ProposalRow::new(0, "20260829", "AA0309").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + assert_eq!(differences.len(), 1); + assert_eq!(differences[0].field, DifferenceField::Date); +} + +#[test] +fn a_present_voucher_reports_a_party_the_book_disagrees_with() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").party("Bravo Industries")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + let party = differences + .iter() + .find(|difference| difference.field == DifferenceField::Party) + .expect("party difference"); + assert_eq!(party.proposed.as_deref(), Some("Alpha Traders")); + assert_eq!(party.observed.as_deref(), Some("Bravo Industries")); +} + +#[test] +fn a_party_difference_echoes_the_source_spelling_not_its_catalog_binding() { + // This test needs a party that binds decisively while being spelled + // differently from the master it binds to -- otherwise there is nothing to + // echo and the assertion is vacuous. A case fold used to serve, and since + // "Rectify unqualified folded ledger binding" it does not: a folded name + // suggests candidates and no longer resolves. An identifier embedded in the + // master name is the remaining basis that decides without byte equality, + // so the source spells the identifier its own way and binds anyway. + let catalog = catalog_of(&["Alpha Traders 9876543210"]); + let window = + window(&[BookRow::new("book-1", "20260812", "AA0118").party_field("Bravo Industries")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .party("ALPHA 9876543210") + .build()]; + let report = run( + &window, + &catalog, + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + let party = differences + .iter() + .find(|difference| difference.field == DifferenceField::Party) + .expect("party difference"); + assert_eq!(party.proposed.as_deref(), Some("ALPHA 9876543210")); + assert_eq!(party.observed.as_deref(), Some("Bravo Industries")); +} + +#[test] +fn an_agreeing_present_voucher_reports_no_differences() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + assert!(differences.is_empty()); +} + +// --- resemblance never decides ----------------------------------------- + +#[test] +fn date_party_and_amount_together_still_only_resemble() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::ResemblesBookVoucher); + assert_eq!( + entry.undecided().expect("undecided").candidates[0].rule, + CandidateRule::SameDatePartyAmount + ); +} + +#[test] +fn an_amount_recurring_across_unrelated_parties_only_resembles() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").party("Bravo Industries")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert_eq!( + entry.undecided().expect("undecided").candidates[0].rule, + CandidateRule::SameDateAmount + ); +} + +#[test] +fn no_candidate_is_marked_best_and_no_score_is_emitted() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118"), + BookRow::new("book-2", "20260812", "AA0119"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let serialized = serde_json::to_value(only(&report)).expect("serialize"); + let object = serialized.as_object().expect("object"); + // The refusal carries no field that names a match. A status does not + // disarm a value printed beside it, so there must be no such value. + assert_eq!( + object.get("presence").and_then(serde_json::Value::as_str), + Some("possibly_present") + ); + for forbidden in [ + "book_key", + "basis", + "best", + "score", + "preferred", + "suggested", + ] { + assert!(object.get(forbidden).is_none(), "{forbidden} leaked"); + } + let text = serialized.to_string(); + assert!(!text.contains("score")); +} + +#[test] +fn candidates_are_ordered_by_rule_then_key_and_never_by_similarity() { + let window = window(&[ + BookRow::new("book-2", "20260812", "AA0118"), + BookRow::new("book-1", "20260819", "AA0119"), + ]); + // book-2 shares date, party and amount; book-1 shares party and amount. + let proposals = [ProposalRow::new(0, "20260812", "AA0999").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let candidates = &only(&report).undecided().expect("undecided").candidates; + assert_eq!(candidates[0].rule, CandidateRule::SameDatePartyAmount); + assert_eq!(candidates[0].book_key, "book-2"); + assert_eq!(candidates[1].rule, CandidateRule::SamePartyAmount); +} + +// --- party matching is master_binding ----------------------------------- + +#[test] +fn a_party_binds_on_an_embedded_identifier_before_any_name() { + let names = [ + "Alpha (5550000001)", + "Alpha Traders", + "Sales Account", + "Output CGST 9%", + "Output SGST 9%", + ]; + let window = window(&[BookRow::new("book-1", "20260812", "AA0118") + .party("Alpha (5550000001)") + .rows(vec![ + ["Alpha (5550000001)", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ["Output SGST 9%", "900.00"], + ])]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999") + .party("ALPHA. BRAVO 5550000001") + .rows(vec![ + ["ALPHA. BRAVO 5550000001", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ["Output SGST 9%", "900.00"], + ]) + .build()]; + let report = run( + &window, + &catalog_of(&names), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert_eq!( + entry.party, + PartyOutcome::Bound { + catalog_name: "Alpha (5550000001)".to_string() + } + ); + assert_eq!( + entry.undecided().expect("undecided").candidates[0].rule, + CandidateRule::SameDatePartyAmount + ); +} + +#[test] +fn an_ambiguous_party_is_compared_against_every_candidate_never_one() { + let names = [ + "Delta Trading Company", + "Delta Trading Corporation", + "Sales Account", + "Output CGST 9%", + "Output SGST 9%", + ]; + let window = window(&[BookRow::new("book-1", "20260819", "AA0118") + .party("Delta Trading Corporation") + .rows(vec![ + ["Delta Trading Corporation", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ["Output SGST 9%", "900.00"], + ])]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999") + .party("Delta Trading") + .rows(vec![ + ["Delta Trading", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ["Output SGST 9%", "900.00"], + ]) + .build()]; + let report = run( + &window, + &catalog_of(&names), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(matches!(entry.party, PartyOutcome::Ambiguous { .. })); + // The date differs, so only a party-and-amount rule can fire — and it only + // fires because both candidate names were compared. + assert_eq!( + entry.undecided().expect("undecided").candidates[0].rule, + CandidateRule::SamePartyAmount + ); +} + +#[test] +fn a_party_with_nothing_resembling_it_still_permits_absent() { + let window = window(&[BookRow::new("book-1", "20260819", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999") + .party("Zulu Enterprises") + .rows(vec![ + ["Zulu Enterprises", "-22500.00"], + ["Sales Account", "22500.00"], + ]) + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(matches!(entry.party, PartyOutcome::Unmatched { .. })); + assert!(entry.is_absent()); +} + +#[test] +fn a_party_name_family_withholds_absent_because_the_comparison_never_ran() { + let mut names: Vec = (1..=30) + .map(|index| format!("Echo Party {index:03}")) + .collect(); + names.extend( + ["Sales Account", "Output CGST 9%", "Output SGST 9%"] + .iter() + .map(|name| (*name).to_string()), + ); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("catalog"); + let window = window(&[]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999") + .party("Echo Party 0") + .rows(vec![ + ["Echo Party 0", "-22500.00"], + ["Sales Account", "22500.00"], + ]) + .build()]; + let report = run( + &window, + &catalog, + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(!entry.is_absent(), "an uncompared family is not an absence"); + assert_eq!(reason(entry), UndecidedReason::PartyNotDecidable); + assert!(entry.undecided().expect("undecided").candidates.is_empty()); +} + +#[test] +fn a_proposal_without_a_party_still_runs_the_party_independent_rules() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let mut proposal = ProposalRow::new(0, "20260812", "AA0999"); + proposal.party = None; + let proposals = [proposal.build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert_eq!(entry.party, PartyOutcome::NotSupplied); + assert_eq!( + entry.undecided().expect("undecided").candidates[0].rule, + CandidateRule::SameDateAmount + ); +} + +// --- magnitude --------------------------------------------------------- + +#[test] +fn an_empty_proposal_entry_list_is_refused_at_the_core_boundary() { + assert_eq!( + ProposedVoucher::new(ProposedVoucherInput { + position: 0, + date: "20260812", + voucher_type: "Sales", + voucher_number: Some("AA0118"), + remote_id: None, + party: None, + entries: &[], + }) + .expect_err("empty accounting data"), + PresenceError::EntriesEmpty + ); +} + +#[test] +fn both_sides_derive_one_magnitude_from_the_same_entries() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").rows(vec![ + ["Alpha Traders", "-11800"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.000"], + ["Output SGST 9%", "900"], + ])]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!( + only(&report).undecided().expect("undecided").candidates[0].rule, + CandidateRule::SameDatePartyAmount + ); +} + +#[test] +fn an_unbalanced_book_voucher_is_reported_and_still_matched() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").rows(vec![ + ["Alpha Traders", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ])]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(report.observations().unbalanced_voucher_count, 1); + assert_eq!(report.observations().unbalanced_vouchers, vec!["book-1"]); + assert_eq!(only(&report).present_book_key(), Some("book-1")); +} + +#[test] +fn unbalanced_observation_listing_is_sorted_before_its_cap() { + let rows = [ + "book-z", "book-a", "book-b", "book-c", "book-d", "book-e", "book-f", "book-g", "book-h", + "book-i", "book-j", "book-k", "book-l", "book-m", "book-n", "book-o", "book-p", "book-q", + "book-r", "book-s", "book-t", "book-u", "book-v", "book-w", "book-x", "book-y", + ] + .map(|key| BookRow::new(key, "20260812", "AA0118").rows(vec![["Alpha Traders", "-1.00"]])); + let window = window(&rows); + let proposals = [ProposalRow::new(0, "20260812", "AA0999").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + + assert_eq!(report.observations().unbalanced_voucher_count, 26); + assert_eq!( + report.observations().unbalanced_vouchers, + (b'a'..=b'y') + .map(|suffix| format!("book-{}", char::from(suffix))) + .collect::>() + ); +} + +// --- book observations -------------------------------------------------- + +#[test] +fn duplicate_voucher_numbers_in_the_book_are_reported_without_being_asked_for() { + let window = window(&[ + BookRow::new("book-1", "20260803", "AA0118"), + BookRow::new("book-2", "20260814", "AA0118").party("Bravo Industries"), + BookRow::new("book-3", "20260815", "AA0120").party("Charlie Minerals"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let observations = report.observations(); + assert_eq!(observations.duplicate_number_group_count, 1); + assert!(!observations.duplicate_numbers_truncated); + let group = &observations.duplicate_numbers[0]; + assert_eq!(group.voucher_number, "AA0118"); + assert_eq!(group.book_voucher_count, 2); + assert_eq!(group.book_keys, vec!["book-1", "book-2"]); +} + +#[test] +fn duplicate_observation_keys_are_sorted_before_the_listing_is_capped() { + let window = window(&[ + BookRow::new("book-10", "20260812", "AA0118"), + BookRow::new("book-09", "20260812", "AA0118"), + BookRow::new("book-08", "20260812", "AA0118"), + BookRow::new("book-07", "20260812", "AA0118"), + BookRow::new("book-06", "20260812", "AA0118"), + BookRow::new("book-05", "20260812", "AA0118"), + BookRow::new("book-04", "20260812", "AA0118"), + BookRow::new("book-03", "20260812", "AA0118"), + BookRow::new("book-02", "20260812", "AA0118"), + BookRow::new("book-01", "20260812", "AA0118"), + BookRow::new("book-00", "20260812", "AA0118"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0999").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let group = &report.observations().duplicate_numbers[0]; + assert_eq!(group.book_voucher_count, MAX_KEYS_PER_DUPLICATE_GROUP + 1); + assert_eq!( + group.book_keys, + (0..MAX_KEYS_PER_DUPLICATE_GROUP) + .map(|index| format!("book-{index:02}")) + .collect::>(), + "the capped diagnostic is stable even when the transport orders rows differently" + ); +} + +#[test] +fn book_vouchers_no_proposal_reached_are_counted() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118"), + BookRow::new("book-2", "20260819", "AA0119").party("Charlie Minerals"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(report.observations().unmatched_book_vouchers, 1); + assert_eq!(report.observations().window_voucher_count, 2); +} + +// --- control totals ----------------------------------------------------- + +#[test] +fn totals_reconcile_a_mixed_run_and_reproduce_the_engagement_that_blocked() { + // Twenty invoices proposed; fifteen already keyed by hand. + let book: Vec = (1..=15) + .map(|index| { + BookRow::new( + Box::leak(format!("book-{index}").into_boxed_str()), + "20260812", + Box::leak(format!("AA{index:04}").into_boxed_str()), + ) + }) + .collect(); + let window = window(&book); + let proposals: Vec = (1..=20) + .map(|index| { + ProposalRow::new( + index - 1, + "20260812", + Box::leak(format!("AA{index:04}").into_boxed_str()), + ) + .rows(vec![ + ["Alpha Traders", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ["Output SGST 9%", "900.00"], + ]) + .build() + }) + .collect(); + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let totals = report.totals(); + assert_eq!(totals.requested, 20); + assert_eq!(totals.present, 15); + // The five that are not in the book all resemble the fifteen that are — + // same party, same date, same amount — so none is silently absent. + assert_eq!(totals.present + totals.possibly_present + totals.absent, 20); + assert_eq!(totals.possibly_present, 5); + assert_eq!(totals.absent, 0); +} + +#[test] +fn totals_always_partition_the_requested_set() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118"), + BookRow::new("book-2", "20260812", "AA0119").party("Bravo Industries"), + ]); + let proposals = [ + ProposalRow::new(0, "20260812", "AA0118").build(), + ProposalRow::new(1, "20260813", "AA0125") + .party("Charlie Minerals") + .build(), + ProposalRow::new(2, "20260812", "AA0126").build(), + ]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let totals = report.totals(); + assert_eq!(totals.requested, 3); + assert_eq!( + totals.present + totals.possibly_present + totals.absent, + totals.requested + ); + assert_eq!(report.absent().count(), totals.absent); + assert_eq!(report.present().count(), totals.present); + assert_eq!(report.possibly_present().count(), totals.possibly_present); +} + +// --- boundary refusals -------------------------------------------------- + +#[test] +fn an_empty_proposal_set_is_refused() { + let window = window(&[]); + assert_eq!( + PresenceRequest::new( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &[] + ) + .expect_err("empty"), + PresenceError::ProposalsEmpty + ); +} + +#[test] +fn 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) + .map(|i| Box::leak(format!("Party Key {i}").into_boxed_str()) as &'static str) + .collect::>(); + let rows = (0..999) + .map(|i| { + BookRow::new( + Box::leak(format!("book-{i}").into_boxed_str()), + "20260812", + "N", + ) + .rows(names.iter().map(|name| [*name, "0.00"]).collect()) + .party_field(names[0]) + .build() + }) + .collect::>(); + let observed = BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + rows, + ) + .expect("window"); + let proposals = (0..500) + .map(|i| ProposalRow::new(i, "20260812", "P").party("Party").build()) + .collect::>(); + assert!(proposals.len() * observed.vouchers().len() < MAX_PRESENCE_COMPARISONS); + let catalog = catalog_of(&names); + let parties = bind_parties(&catalog, &proposals).expect("actual party binding"); + assert_eq!( + parties[0].compare_keys.len(), + 25, + "fixture must retain every party fanout key" + ); + let index = WindowIndex::build(&observed); + let per_proposal = + resemblance_work_units(&proposals[..1], &parties[..1], &index).expect("unit cost"); + let admitted_count = MAX_PRESENCE_WORK_UNITS / per_proposal; + assert!(admitted_count > 0 && admitted_count < proposals.len()); + assert!( + resemblance_work_units( + &proposals[..admitted_count], + &parties[..admitted_count], + &index + ) + .expect("admitted count") + <= MAX_PRESENCE_WORK_UNITS + ); + assert!( + resemblance_work_units( + &proposals[..admitted_count + 1], + &parties[..admitted_count + 1], + &index + ) + .expect("refused count") + > MAX_PRESENCE_WORK_UNITS + ); + assert!(PresenceRequest::new( + &observed, + &catalog, + &numbering(NumberingMethod::Manual), + &proposals[..admitted_count] + ) + .is_ok()); + assert_eq!( + PresenceRequest::new( + &observed, + &catalog, + &numbering(NumberingMethod::Manual), + &proposals[..admitted_count + 1] + ) + .expect_err("real admission refuses"), + PresenceError::ComparisonWorkTooLarge + ); +} + +#[test] +fn a_stock_item_catalog_cannot_be_used_to_compare_parties() { + let catalog = MasterCatalog::new(MasterClass::StockItem, LEDGERS).expect("catalog"); + let window = window(&[]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + assert_eq!( + PresenceRequest::new( + &window, + &catalog, + &numbering(NumberingMethod::Manual), + &proposals + ) + .expect_err("class"), + PresenceError::CatalogClassInvalid + ); +} + +#[test] +fn observed_input_refuses_blank_unsafe_and_invalid_fields() { + let rows = [["Alpha Traders", "-1.00"], ["Sales Account", "1.00"]]; + let good = entries(&rows); + let base = ObservedVoucher { + key: "book-1", + date: "20260812", + voucher_type: "Sales", + voucher_number: Some("AA0118"), + remote_id: None, + party: Some("Alpha Traders"), + entries: &good, + cancelled: false, + optional: false, + }; + assert_eq!( + BookVoucher::observed(ObservedVoucher { key: " ", ..base }).expect_err("blank"), + PresenceError::TextBlank + ); + assert_eq!( + BookVoucher::observed(ObservedVoucher { + voucher_type: "Sales\u{0007}", + ..base + }) + .expect_err("unsafe"), + PresenceError::TextUnsafe + ); + assert_eq!( + BookVoucher::observed(ObservedVoucher { + date: "2026-08-12", + ..base + }) + .expect_err("date"), + PresenceError::DateInvalid + ); + let bad = [["Alpha Traders", "one thousand"]]; + let bad = entries(&bad); + assert_eq!( + BookVoucher::observed(ObservedVoucher { + entries: &bad, + ..base + }) + .expect_err("amount"), + PresenceError::AmountInvalid + ); +} + +#[test] +fn a_window_bounds_aggregate_ledger_memberships_before_indexing() { + let ledgers = (0..MAX_ENTRIES_PER_VOUCHER) + .map(|position| Box::leak(format!("Ledger {position:04}").into_boxed_str()) as &'static str) + .collect::>(); + let entries = ledgers + .iter() + .map(|ledger| ObservedEntry { + ledger, + amount: "1.00", + }) + .collect::>(); + let vouchers = (0..(MAX_WINDOW_LEDGER_MEMBERSHIPS / MAX_ENTRIES_PER_VOUCHER + 1)) + .map(|position| { + BookVoucher::observed(ObservedVoucher { + key: Box::leak(format!("book-{position:03}").into_boxed_str()), + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: &entries, + cancelled: false, + optional: false, + }) + .expect("voucher below its own entry limit") + }) + .collect(); + assert_eq!( + BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + vouchers, + ) + .expect_err("derived index membership budget"), + PresenceError::WindowLedgerMembershipsTooMany + ); +} + +#[test] +fn raw_observations_are_bounded_before_voucher_conversion() { + let entry = ObservedEntry { + ledger: "Cash", + amount: "1.00", + }; + let rows = vec![entry; MAX_WINDOW_RAW_ENTRY_WORK + 1]; + assert_eq!( + BookWindow::from_observations( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + [ObservedVoucher { + key: "book-1", + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: &rows, + cancelled: false, + optional: false + }], + ) + .expect_err("raw entries must be refused before parsing"), + PresenceError::WindowRawEntryWorkTooLarge + ); + let long = "x".repeat(MAX_WINDOW_RAW_ENTRY_BYTES + 1); + let oversized = [ObservedEntry { + ledger: &long, + amount: "1.00", + }]; + assert_eq!( + BookWindow::from_observations( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + [ObservedVoucher { + key: "book-2", + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: &oversized, + cancelled: false, + optional: false + }], + ) + .expect_err("raw bytes must be refused before cloning"), + PresenceError::WindowRawEntryBytesTooLarge + ); + let admitted_entries = (0..(MAX_WINDOW_RAW_ENTRY_WORK / MAX_ENTRIES_PER_VOUCHER)) + .map(|_| vec![entry; MAX_ENTRIES_PER_VOUCHER]) + .collect::>(); + let admitted = admitted_entries + .iter() + .enumerate() + .map(|(position, entries)| ObservedVoucher { + key: Box::leak(format!("admitted-{position}").into_boxed_str()), + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries, + cancelled: false, + optional: false, + }); + assert!(BookWindow::from_observations( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + admitted + ) + .is_ok()); +} + +#[test] +fn raw_entry_work_is_bounded_across_valid_voucher_sized_rows() { + let entry = ObservedEntry { + ledger: "Cash", + amount: "1.00", + }; + let full_voucher_entries = vec![entry; MAX_ENTRIES_PER_VOUCHER]; + let mut rows = (0..(MAX_WINDOW_RAW_ENTRY_WORK / MAX_ENTRIES_PER_VOUCHER)) + .map(|position| ObservedVoucher { + key: Box::leak(format!("full-{position}").into_boxed_str()), + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: &full_voucher_entries, + cancelled: false, + optional: false, + }) + .collect::>(); + rows.push(ObservedVoucher { + key: "one-over", + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: std::slice::from_ref(&entry), + cancelled: false, + optional: false, + }); + assert_eq!( + BookWindow::from_observations( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + rows, + ) + .expect_err("the aggregate raw-entry limit must span valid rows"), + PresenceError::WindowRawEntryWorkTooLarge + ); +} + +#[test] +fn raw_entry_bytes_admit_exact_limit_and_refuse_the_next_byte() { + let amount = "1"; + let ledger_1023: &'static str = Box::leak("x".repeat(1_023).into_boxed_str()); + let ledger_1024: &'static str = Box::leak("y".repeat(1_024).into_boxed_str()); + let entry_1024 = ObservedEntry { + ledger: ledger_1023, + amount, + }; + let exact_entries = vec![entry_1024; MAX_WINDOW_RAW_ENTRY_BYTES / 1_024]; + let exact_rows = [ + ObservedVoucher { + key: "bytes-0", + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: &exact_entries[..MAX_ENTRIES_PER_VOUCHER], + cancelled: false, + optional: false, + }, + ObservedVoucher { + key: "bytes-1", + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: &exact_entries[MAX_ENTRIES_PER_VOUCHER..2 * MAX_ENTRIES_PER_VOUCHER], + cancelled: false, + optional: false, + }, + ObservedVoucher { + key: "bytes-2", + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: &exact_entries[2 * MAX_ENTRIES_PER_VOUCHER..], + cancelled: false, + optional: false, + }, + ]; + assert!(BookWindow::from_observations( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + exact_rows, + ) + .is_ok()); + let extra = ObservedEntry { + ledger: ledger_1024, + amount, + }; + let mut over_tail = exact_entries[2 * MAX_ENTRIES_PER_VOUCHER..].to_vec(); + *over_tail.last_mut().expect("nonempty tail") = extra; + let over_rows = [ + exact_rows[0], + exact_rows[1], + ObservedVoucher { + entries: &over_tail, + ..exact_rows[2] + }, + ]; + assert_eq!( + over_rows + .iter() + .flat_map(|row| row.entries) + .map(|entry| entry.ledger.len() + entry.amount.len()) + .sum::(), + MAX_WINDOW_RAW_ENTRY_BYTES + 1, + ); + assert_eq!( + BookWindow::from_observations( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + over_rows, + ) + .expect_err("one byte over the aggregate raw-byte limit must refuse"), + PresenceError::WindowRawEntryBytesTooLarge + ); +} + +#[test] +fn raw_observation_voucher_count_budget_counts_zero_entry_admissions() { + let mut budget = RawObservationBudget::default(); + for _ in 0..MAX_WINDOW_VOUCHERS { + assert!(budget.admit(std::iter::empty()).is_ok()); + } + assert_eq!( + budget + .admit(std::iter::empty()) + .expect_err("next voucher exceeds the count budget"), + PresenceError::WindowTooLarge + ); +} + +#[test] +fn a_window_bounds_aggregate_ledger_key_bytes_before_indexing() { + let ledgers = (0..(MAX_WINDOW_LEDGER_KEY_BYTES / MAX_TEXT_CHARS + 1)) + .map(|position| { + Box::leak(format!("{position:04}{}", "x".repeat(MAX_TEXT_CHARS - 4)).into_boxed_str()) + as &'static str + }) + .collect::>(); + let entries = ledgers + .iter() + .map(|ledger| ObservedEntry { + ledger, + amount: "1.00", + }) + .collect::>(); + let voucher = BookVoucher::observed(ObservedVoucher { + key: "book-1", + date: "20260812", + voucher_type: "Sales", + voucher_number: None, + remote_id: None, + party: None, + entries: &entries, + cancelled: false, + optional: false, + }) + .expect("voucher below its own bounds"); + assert_eq!( + BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + vec![voucher], + ) + .expect_err("derived index key-byte budget"), + PresenceError::WindowLedgerKeyBytesTooLarge + ); +} + +#[test] +fn every_error_carries_a_distinct_stable_reason_code() { + let codes = [ + PresenceError::WindowRangeInvalid, + PresenceError::WindowTooLarge, + PresenceError::WindowLedgerMembershipsTooMany, + PresenceError::WindowLedgerKeyBytesTooLarge, + PresenceError::WindowVoucherOutsideRange, + PresenceError::WindowDuplicateVoucherKey, + PresenceError::WindowDoesNotCover, + PresenceError::ProposalsEmpty, + PresenceError::TooManyProposals, + PresenceError::TooManyEntries, + PresenceError::NumberingMethodUndeclared, + PresenceError::NumberingMethodConflict, + PresenceError::TextBlank, + PresenceError::TextTooLong, + PresenceError::TextUnsafe, + PresenceError::DateInvalid, + PresenceError::AmountInvalid, + PresenceError::CatalogClassInvalid, + ] + .iter() + .map(PresenceError::safe_reason_code) + .collect::>(); + assert_eq!(codes.len(), 18); + assert!(codes.iter().all(|code| code.starts_with("presence_"))); +} + +#[test] +fn every_undecided_reason_carries_a_distinct_stable_code() { + let codes = [ + UndecidedReason::RemoteIdCollision, + UndecidedReason::BookNumberCollision, + UndecidedReason::ProposalNumberCollision, + UndecidedReason::MatchedVoucherNotPosted, + UndecidedReason::NumberNotDecisive, + UndecidedReason::VoucherTypeNotObserved, + UndecidedReason::ResemblesBookVoucher, + UndecidedReason::PartyNotDecidable, + ] + .iter() + .map(|reason| reason.safe_reason_code()) + .collect::>(); + assert_eq!(codes.len(), 8); +} + +// --- the aggregate candidate budget is a second source of "incomplete" ------ + +/// `master_binding` spends an aggregate candidate-byte budget in entity order +/// while the report is built, so an entity's candidate list can arrive **empty +/// with `candidates_truncated`** for a reason that has nothing to do with its +/// own name — pressure from earlier entities in the same report. That is a new +/// source of a signal this contract already acts on, and the danger is reading +/// "no candidates" as "nothing in this book resembles this party". +#[test] +fn an_aggregately_truncated_candidate_list_withholds_absent() { + let binding = master_binding::EntityBinding { + position: 0, + source_name: "Delta Trading".to_string(), + status: BindingStatus::Ambiguous(master_binding::Unresolved { + reason: master_binding::UnboundReason::NearMiss, + unresolved_identity: Vec::new(), + // Empty, yet seven candidates were found before the budget ran + // out. Under the typed listing this is `Truncated` with nothing + // listed, which is now a state the compiler makes me handle. + candidates: master_binding::Candidates::Truncated { + listed: Vec::new(), + found: 7, + count_is_lower_bound: false, + }, + }), + }; + let resolution = resolution_of(&binding); + assert!( + resolution.compare_keys.is_empty(), + "no name survived to be compared" + ); + assert!( + resolution.incomplete, + "names that were never compared cannot license an absence" + ); +} + +/// The contrast that keeps the rule honest: a party with no candidates and no +/// truncation genuinely has nothing resembling it in the catalog, so no posted +/// voucher can be carrying it and `Absent` stays available. +#[test] +fn an_untruncated_empty_candidate_list_still_permits_absent() { + let binding = master_binding::EntityBinding { + position: 0, + source_name: "Zulu Enterprises".to_string(), + status: BindingStatus::Unmatched(master_binding::Unresolved { + reason: master_binding::UnboundReason::NoCandidate, + unresolved_identity: Vec::new(), + candidates: master_binding::Candidates::None, + }), + }; + let resolution = resolution_of(&binding); + assert!(resolution.compare_keys.is_empty()); + assert!(!resolution.incomplete); +} + +/// The overloaded-empty-vector shape has been got wrong twice in two surfaces, +/// so this contract states what its own empty list means — and the honest +/// statement is not "one thing". An empty list is always a *proposal-side* +/// condition: the book was never consulted, or was consulted about something +/// undecidable before it could point anywhere. What it never means is +/// "nothing in the book resembles this" — only `Absent` means that, and +/// `Absent` carries no list at all. +/// +/// An earlier version of this test asserted the stronger claim that empty +/// implies `PartyNotDecidable`. That was false the moment a second empty-list +/// reason existed, and it passed only because no case exercised one. The +/// allow-list below is the real rule and fails closed: a new reason that can +/// arrive empty must be added here deliberately. +const EMPTY_LIST_REASONS: [UndecidedReason; 4] = [ + UndecidedReason::PartyNotDecidable, + UndecidedReason::RemoteIdEvidenceUnavailable, + UndecidedReason::ProposalNumberCollision, + UndecidedReason::RemoteIdCollision, +]; + +#[test] +fn an_empty_candidate_list_is_always_a_proposal_side_condition() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let mut names: Vec = (1..=30) + .map(|index| format!("Echo Party {index:03}")) + .collect(); + names.extend(LEDGERS.iter().map(|name| (*name).to_string())); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("catalog"); + let money = |ledger: &'static str| vec![[ledger, "-99.00"], ["Sales Account", "99.00"]]; + let proposals = [ + // Party family that cannot be distinguished. + ProposalRow::new(0, "20260812", "AA0501") + .party("Echo Party 0") + .rows(money("Echo Party 0")) + .build(), + // Two proposals sharing a manual number the book does not hold. + ProposalRow::new(1, "20260812", "AA0502") + .party("Charlie Minerals") + .rows(money("Charlie Minerals")) + .build(), + ProposalRow::new(2, "20260812", "AA0502") + .party("Charlie Minerals") + .rows(money("Charlie Minerals")) + .build(), + // Two proposals sharing a REMOTEID the book does not hold. + ProposalRow::new(3, "20260812", "AA0503") + .remote_id("tally-9") + .party("Charlie Minerals") + .rows(money("Charlie Minerals")) + .build(), + ProposalRow::new(4, "20260812", "AA0504") + .remote_id("tally-9") + .party("Charlie Minerals") + .rows(money("Charlie Minerals")) + .build(), + ]; + let report = run( + &window, + &catalog, + &numbering(NumberingMethod::Manual), + &proposals, + ); + let mut empties = 0; + for entry in report.vouchers() { + let Some(undecided) = entry.undecided() else { + continue; + }; + if undecided.candidates.is_empty() { + empties += 1; + assert!( + EMPTY_LIST_REASONS.contains(&undecided.reason), + "{:?} may not arrive with an empty candidate list", + undecided.reason + ); + assert!(!undecided.candidates_truncated); + } else { + assert_eq!( + undecided.candidates_truncated, + undecided.candidates.len() < undecided.candidate_count + ); + } + } + assert_eq!(empties, 5, "every empty-list reason must be exercised here"); + let totals = report.totals(); + assert_eq!( + totals.present + totals.possibly_present + totals.absent, + totals.requested + ); +} + +/// Two source rows claiming one identity are undecidable whether or not the +/// book holds that identity. Consulting the book first let both fall through +/// to a resemblance verdict, or to `Absent` — reporting colliding rows as safe +/// to import. +#[test] +fn proposals_sharing_a_remote_id_collide_even_when_the_book_has_none() { + let window = window(&[BookRow::new("book-1", "20260819", "AA0130").party("Bravo Industries")]); + let proposals = [ + ProposalRow::new(0, "20260812", "AA0601") + .remote_id("tally-9") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-42.00"], + ["Sales Account", "42.00"], + ]) + .build(), + ProposalRow::new(1, "20260812", "AA0602") + .remote_id("tally-9") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-43.00"], + ["Sales Account", "43.00"], + ]) + .build(), + ]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!( + report.totals().absent, + 0, + "colliding rows are not safe to import" + ); + for entry in report.vouchers() { + assert_eq!(reason(entry), UndecidedReason::RemoteIdCollision); + } +} + +/// The overloaded-empty-vector shape has now been got wrong twice in two +/// surfaces, so this contract's *own* output must not repeat it. Here an empty +/// candidate list is one fact and not three: it happens only when the party +/// comparison could not run, and truncation only ever cuts a list that is +/// otherwise full. Held by construction today; held by test from now on. +#[test] +fn an_empty_candidate_list_means_exactly_one_thing_in_this_contract() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118"), + BookRow::new("book-2", "20260812", "AA0118").party("Bravo Industries"), + BookRow::new("book-3", "20260819", "AA0130").party("Charlie Minerals"), + ]); + let mut names: Vec = (1..=30) + .map(|index| format!("Echo Party {index:03}")) + .collect(); + names.extend(LEDGERS.iter().map(|name| (*name).to_string())); + let catalog = MasterCatalog::new(MasterClass::Ledger, &names).expect("catalog"); + let proposals = [ + // Collides on a number carried by two book vouchers. + ProposalRow::new(0, "20260812", "AA0118").build(), + // Resembles on date, party and amount. + ProposalRow::new(1, "20260812", "AA0777").build(), + // Party is an undistinguishable family: withheld, not absent. + ProposalRow::new(2, "20260812", "AA0778") + .party("Echo Party 0") + .rows(vec![["Echo Party 0", "-99.00"], ["Sales Account", "99.00"]]) + .build(), + // Nothing resembles it at all. + ProposalRow::new(3, "20260812", "AA0779") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-13.00"], + ["Sales Account", "13.00"], + ]) + .build(), + ]; + let report = run( + &window, + &catalog, + &numbering(NumberingMethod::Manual), + &proposals, + ); + + let mut seen_empty = 0; + for entry in report.vouchers() { + let Some(undecided) = entry.undecided() else { + continue; + }; + if undecided.candidates.is_empty() { + seen_empty += 1; + assert_eq!( + undecided.reason, + UndecidedReason::PartyNotDecidable, + "an empty candidate list may only mean the comparison did not run" + ); + assert!(!undecided.candidates_truncated); + assert_eq!(undecided.candidate_count, 0); + } else { + // A listed count and a true count that disagree must say so. + assert_eq!( + undecided.candidates_truncated, + undecided.candidates.len() < undecided.candidate_count + ); + } + } + assert_eq!(seen_empty, 1, "the withheld-family case must be exercised"); + // And the whole run still partitions. + let totals = report.totals(); + assert_eq!( + totals.present + totals.possibly_present + totals.absent, + totals.requested + ); +} + +// --- one book voucher satisfies at most one proposal -------------------- + +#[test] +fn two_proposals_reaching_one_book_voucher_are_both_demoted() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").remote_id("bridge-txn-1")]); + let proposals = [ + // Reaches book-1 by REMOTEID. + ProposalRow::new(0, "20260812", "AA9999") + .remote_id("bridge-txn-1") + .build(), + // Reaches the same voucher by its manual number. + ProposalRow::new(1, "20260812", "AA0118").build(), + ]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + // The first proposal's manual number is absent from the book, so its + // observed REMOTEID cannot override that contradictory identity signal. + assert_eq!(report.totals().present, 1); + assert_eq!( + reason(&report.vouchers()[0]), + UndecidedReason::IdentityConflict + ); + assert!(report.vouchers()[1].present_book_key().is_some()); +} + +#[test] +fn distinct_proposals_reaching_distinct_vouchers_both_stay_present() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118").remote_id("bridge-txn-1"), + BookRow::new("book-2", "20260813", "AA0119").party("Bravo Industries"), + ]); + let proposals = [ + ProposalRow::new(0, "20260812", "AA0118").build(), + ProposalRow::new(1, "20260813", "AA0119") + .party("Bravo Industries") + .build(), + ]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(report.totals().present, 2); +} + +// --- two identity signals that disagree --------------------------------- + +#[test] +fn a_number_match_contradicted_by_a_different_remote_id_does_not_settle() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").remote_id("tally-1")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .remote_id("tally-2") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::IdentityConflict); +} + +#[test] +fn a_number_match_without_the_proposed_observed_remote_id_does_not_settle() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .remote_id("previous-id") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::IdentityConflict); +} + +#[test] +fn a_number_match_agreeing_with_the_remote_id_still_settles() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").remote_id("tally-1")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .remote_id("tally-1") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + // The REMOTEID decides it first; either basis is an identity. + assert!(only(&report).present_book_key().is_some()); +} + +#[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") + .party("Bravo Industries") + .build()], + ) + .expect("window"); + let proposals = [ProposalRow::new(0, "20260812", "AA0777") + .remote_id("tally-1") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-55.00"], + ["Sales Account", "55.00"], + ]) + .build()]; + let report = run( + &unread, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!( + !entry.is_absent(), + "the proposal's strongest key was never compared" + ); + assert_eq!(reason(entry), UndecidedReason::RemoteIdEvidenceUnavailable); +} + +#[test] +fn the_same_proposal_is_absent_when_the_window_did_read_remote_ids() { + let window = window(&[BookRow::new("book-1", "20260819", "AA0130").party("Bravo Industries")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0777") + .remote_id("tally-1") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-55.00"], + ["Sales Account", "55.00"], + ]) + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert!(only(&report).is_absent()); +} + +// --- a window not proven complete is not a window that found nothing ---- +// +// This is the safety invariant the construction-time refusal used to buy: +// `PresenceStatus::Absent` must be unreachable from a window whose `read` is +// `Partial`. The gate moved to `decide` (see `PresenceStatus::Absent`'s doc +// comment), so it is proven here instead of by the type system refusing to +// build the window at all. + +#[test] +fn a_partial_window_withholds_absent_even_when_nothing_resembles_the_proposal() { + let partial = BookWindow::observed( + "20260801", + "20260831", + WindowRead::Partial, + RemoteIdEvidence::Observed, + vec![BookRow::new("book-1", "20260819", "AA0130") + .party("Bravo Industries") + .build()], + ) + .expect("a partial read is still a window"); + let proposals = [ProposalRow::new(0, "20260812", "AA0777") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-55.00"], + ["Sales Account", "55.00"], + ]) + .build()]; + let report = run( + &partial, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!( + !entry.is_absent(), + "the window's own read never covered its whole declared range" + ); + assert_eq!(reason(entry), UndecidedReason::WindowNotProvenComplete); +} + +/// The mirror of the test above: identical window contents and an identical +/// proposal, differing only in `WindowRead`. Without this pair, the first +/// test could pass for the wrong reason -- because `Absent` had broken +/// generally, not because `Partial` specifically withholds it. +#[test] +fn the_same_proposal_is_absent_against_the_same_contents_read_completely() { + let complete = + window(&[BookRow::new("book-1", "20260819", "AA0130").party("Bravo Industries")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0777") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-55.00"], + ["Sales Account", "55.00"], + ]) + .build()]; + let report = run( + &complete, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert!(only(&report).is_absent()); +} + +#[test] +fn unread_remote_id_outranks_resemblance_but_keeps_its_candidates() { + let unread = BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::NotRead, + vec![BookRow::new("book-1", "20260812", "AA0118").build()], + ) + .expect("window"); + let proposals = [ProposalRow::new(0, "20260812", "AA0777") + .remote_id("tally-1") + .build()]; + let report = run( + &unread, + &catalog(), + &numbering(NumberingMethod::Automatic), + &proposals, + ); + let entry = only(&report); + assert_eq!(reason(entry), UndecidedReason::RemoteIdEvidenceUnavailable); + assert_eq!( + entry.undecided().expect("undecided").candidates[0].book_key, + "book-1" + ); + assert_eq!(report.observations().unmatched_book_vouchers, 0); +} + +#[test] +fn unread_remote_id_outranks_nondecisive_number_candidates() { + let unread = BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::NotRead, + vec![BookRow::new("book-1", "20260812", "AA0118").build()], + ) + .expect("window"); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .remote_id("tally-1") + .build()]; + let report = run( + &unread, + &catalog(), + &numbering(NumberingMethod::Automatic), + &proposals, + ); + assert_eq!( + reason(only(&report)), + UndecidedReason::RemoteIdEvidenceUnavailable + ); + assert_eq!( + only(&report).undecided().unwrap().candidates[0].rule, + CandidateRule::SharedVoucherNumber + ); +} + +#[test] +fn unread_remote_id_outranks_unobserved_type_number_candidates() { + let unread = BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::NotRead, + vec![BookRow::new("book-1", "20260812", "AA0118").build()], + ) + .expect("window"); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .voucher_type("Receipt") + .remote_id("tally-1") + .build()]; + let report = run( + &unread, + &catalog(), + &NumberingDeclaration::new([("Receipt", NumberingMethod::Manual)]).expect("numbering"), + &proposals, + ); + assert_eq!( + reason(only(&report)), + UndecidedReason::RemoteIdEvidenceUnavailable + ); + assert_eq!( + only(&report).undecided().unwrap().candidates[0].rule, + CandidateRule::SharedVoucherNumber + ); +} + +#[test] +fn remote_identity_reports_an_exact_voucher_type_difference() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118") + .voucher_type("Receipt") + .remote_id("tally-1")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .voucher_type("Sales") + .remote_id("tally-1") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("present") + }; + let difference = differences + .iter() + .find(|item| item.field == DifferenceField::VoucherType) + .expect("type difference serialized"); + assert_eq!(difference.proposed.as_deref(), Some("Sales")); + assert_eq!(difference.observed.as_deref(), Some("Receipt")); + assert_eq!( + serde_json::to_value(difference).expect("serialize difference")["field"], + "voucher_type" + ); +} + +// --- the response cap must not distort the observations ----------------- + +#[test] +fn candidates_dropped_by_the_response_cap_still_count_as_reached() { + let rows: Vec = (1..=30) + .map(|index| { + BookRow::new( + Box::leak(format!("book-{index:02}").into_boxed_str()), + "20260812", + Box::leak(format!("BB{index:04}").into_boxed_str()), + ) + }) + .collect(); + let window = window(&rows); + let proposals = [ProposalRow::new(0, "20260812", "AA0777").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let undecided = only(&report).undecided().expect("undecided"); + assert_eq!(undecided.candidate_count, 30); + assert!(undecided.candidates_truncated); + assert_eq!(undecided.candidates.len(), MAX_CANDIDATES_PER_PROPOSAL); + // All thirty were reached; none may be reported as untouched merely + // because the response could not carry it. + assert_eq!(report.observations().unmatched_book_vouchers, 0); +} + +// --- the party diagnostic reads the party field ------------------------- + +#[test] +fn a_party_difference_compares_the_observed_party_field_not_every_ledger() { + // PARTYLEDGERNAME is Bravo while the entries still name Alpha. + let window = + window(&[BookRow::new("book-1", "20260812", "AA0118").party_field("Bravo Industries")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + let party = differences + .iter() + .find(|difference| difference.field == DifferenceField::Party) + .expect("the party field disagrees and must be reported"); + assert_eq!(party.proposed.as_deref(), Some("Alpha Traders")); + assert_eq!(party.observed.as_deref(), Some("Bravo Industries")); +} + +#[test] +fn a_voucher_with_no_party_field_has_nothing_to_disagree_with() { + let mut row = BookRow::new("book-1", "20260812", "AA0118"); + row.party = None; + let window = window(&[row]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + assert!(differences.is_empty()); +} + +/// `Present` carries the higher bar, so unobserved evidence that could +/// *contradict* it must fail toward not-present. A number match while the +/// proposal's own `REMOTEID` was never compared settles on one identity while +/// the other is unknown — and a wrong `Present` suppresses a real invoice. +#[test] +fn a_number_match_cannot_settle_while_the_proposals_remote_id_is_unread() { + let unread = BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::NotRead, + vec![BookRow::new("book-1", "20260812", "AA0118").build()], + ) + .expect("window"); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .remote_id("tally-1") + .build()]; + let report = run( + &unread, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::RemoteIdEvidenceUnavailable); + // The number match is still shown, so the operator sees what it resembles. + assert_eq!( + entry.undecided().expect("undecided").candidates[0].book_key, + "book-1" + ); +} + +#[test] +fn a_proposal_without_a_remote_id_still_settles_on_an_unread_window() { + let unread = BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::NotRead, + vec![BookRow::new("book-1", "20260812", "AA0118").build()], + ) + .expect("window"); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &unread, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + // Nothing was skipped: this proposal carries no REMOTEID to compare. + assert_eq!(only(&report).present_book_key(), Some("book-1")); +} + +/// Both identity lookups are resolved before either settles. A `REMOTEID` +/// selecting one voucher while the number selects another is a disagreement, +/// and ranking the basis that happened to be checked first is the move this +/// contract refuses everywhere else. +#[test] +fn a_remote_id_and_a_number_selecting_different_vouchers_do_not_settle() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118").remote_id("tally-1"), + BookRow::new("book-2", "20260813", "AA0119").party("Bravo Industries"), + ]); + let proposals = [ProposalRow::new(0, "20260813", "AA0119") + .remote_id("tally-1") + .party("Bravo Industries") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!(entry.present_book_key().is_none()); + assert_eq!(reason(entry), UndecidedReason::IdentityConflict); + // Both contradicting vouchers are shown, each labelled by its own rule. + let candidates = &entry.undecided().expect("undecided").candidates; + assert_eq!(candidates.len(), 2); + assert!(candidates + .iter() + .any(|c| c.book_key == "book-1" && c.rule == CandidateRule::SharedRemoteId)); + assert!(candidates + .iter() + .any(|c| c.book_key == "book-2" && c.rule == CandidateRule::SharedVoucherNumber)); +} + +#[test] +fn a_remote_id_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( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(report.vouchers()[0].present_book_key(), Some("book-1")); +} + +#[test] +fn a_remote_id_and_a_number_agreeing_on_one_voucher_still_settle() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").remote_id("tally-1")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .remote_id("tally-1") + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(only(&report).present_book_key(), Some("book-1")); +} + +/// A source that names no party had no party rule run against it, so an +/// absence rests on date and amount alone — the pair this contract says +/// collides. `Present` by identity is unaffected; only the absence is +/// withheld, and supplying the party is what makes it available again. +#[test] +fn a_proposal_that_names_no_party_cannot_be_reported_absent() { + let window = window(&[BookRow::new("book-1", "20260819", "AA0130").party("Bravo Industries")]); + let mut proposal = ProposalRow::new(0, "20260812", "AA0777"); + proposal.party = None; + proposal.rows = vec![["Charlie Minerals", "-55.00"], ["Sales Account", "55.00"]]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &[proposal.build()], + ); + let entry = only(&report); + assert_eq!(entry.party, PartyOutcome::NotSupplied); + assert!(!entry.is_absent()); + assert_eq!(reason(entry), UndecidedReason::PartyNotSupplied); +} + +#[test] +fn naming_the_party_is_what_makes_absence_available() { + let window = window(&[BookRow::new("book-1", "20260819", "AA0130").party("Bravo Industries")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0777") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-55.00"], + ["Sales Account", "55.00"], + ]) + .build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert!(only(&report).is_absent()); +} + +#[test] +fn a_proposal_that_names_no_party_still_settles_by_identity() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let mut proposal = ProposalRow::new(0, "20260812", "AA0118"); + proposal.party = None; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &[proposal.build()], + ); + assert_eq!(only(&report).present_book_key(), Some("book-1")); +} + +/// A collision between two proposals is a fact about the source. It does not +/// become less true because the book has never seen that voucher type. +#[test] +fn proposals_sharing_a_number_collide_even_for_an_unobserved_type() { + let window = window(&[BookRow::new("book-1", "20260819", "AA0130").party("Bravo Industries")]); + let declaration = + NumberingDeclaration::new([("Part Sale", NumberingMethod::Manual)]).expect("numbering"); + let money = vec![["Charlie Minerals", "-61.00"], ["Sales Account", "61.00"]]; + let proposals = [ + ProposalRow::new(0, "20260812", "AA0801") + .voucher_type("Part Sale") + .party("Charlie Minerals") + .rows(money.clone()) + .build(), + ProposalRow::new(1, "20260812", "AA0801") + .voucher_type("Part Sale") + .party("Charlie Minerals") + .rows(money) + .build(), + ]; + let report = run(&window, &catalog(), &declaration, &proposals); + assert_eq!( + report.totals().absent, + 0, + "colliding rows are not safe to import" + ); + for entry in report.vouchers() { + assert!(!entry.voucher_type_observed); + assert_eq!(reason(entry), UndecidedReason::ProposalNumberCollision); + } +} + +/// A window cannot say "REMOTEID was never read" while carrying one. The two +/// statements contradict, and the contradiction would let a verdict settle on +/// evidence the window itself says was not gathered. +#[test] +fn a_window_declaring_remote_ids_unread_refuses_to_carry_one() { + let carrying = vec![BookRow::new("book-1", "20260812", "AA0118") + .remote_id("tally-1") + .build()]; + assert_eq!( + BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::NotRead, + carrying, + ) + .expect_err("contradiction"), + PresenceError::WindowRemoteIdContradiction + ); + // The same vouchers are fine once the window admits it read the column. + assert!(BookWindow::observed( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + vec![BookRow::new("book-1", "20260812", "AA0118") + .remote_id("tally-1") + .build()], + ) + .is_ok()); +} + +/// A dense window can hold thousands of vouchers sharing one manual number. +/// The response keeps twenty-five of them, so twenty-five is what may be +/// cloned — the count is carried alongside rather than recovered from the +/// vector's length, which is what let the old code allocate the whole set and +/// then throw it away. +#[test] +fn a_large_number_collision_reports_its_true_size_without_listing_it() { + let rows: Vec = (1..=200) + .map(|index| { + BookRow::new( + Box::leak(format!("book-{index:03}").into_boxed_str()), + "20260812", + "AA0118", + ) + }) + .collect(); + let window = window(&rows); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert_eq!(reason(entry), UndecidedReason::BookNumberCollision); + let undecided = entry.undecided().expect("undecided"); + assert_eq!(undecided.candidate_count, 200, "the true size is reported"); + assert_eq!(undecided.candidates.len(), MAX_CANDIDATES_PER_PROPOSAL); + assert!(undecided.candidates_truncated); + // Every one of them was still reached, so none is reported as a voucher no + // proposal came near. + assert_eq!(report.observations().unmatched_book_vouchers, 0); + // And the book-side diagnostic sees the collision it is there to find. + assert_eq!(report.observations().duplicate_number_group_count, 1); +} + +/// A voucher number is content, not a name, and the two are folded +/// differently on purpose. +/// +/// `comparison_key` lowercases and unifies dash and quote variants because +/// §3.3b measured Tally doing that to master *names*. Nothing measured it for +/// numbers, and the fold fails in the silent direction: it produces more +/// matches, a wrong number match is a `Present`, and `Present` tells a caller +/// an invoice is already filed. Two distinct invoices differing only in case +/// would have suppressed one another. +#[test] +fn two_numbers_differing_only_in_case_are_two_numbers() { + let cased = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "aa0118") + .party("Bravo Industries") + .rows(vec![ + ["Bravo Industries", "-4200.00"], + ["Sales Account", "4200.00"], + ]) + .build()]; + let report = run( + &cased, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!( + only(&report).present_book_key(), + None, + "a case variant is a different number until something measures otherwise" + ); + + // Outer padding is normalized, but internal whitespace is content until + // an observed source contract proves otherwise. Folding it could turn two + // distinct invoice numbers into an unsafe `Present` verdict. + let padded_rows = [BookRow::new("book-1", "20260812", "AA 0118")]; + let padded = window(&padded_rows); + let spaced = [ProposalRow::new(0, "20260812", "AA 0118").build()]; + let report = run( + &padded, + &catalog(), + &numbering(NumberingMethod::Manual), + &spaced, + ); + assert_eq!(only(&report).present_book_key(), None); + + let composed = window(&[BookRow::new("book-1", "20260812", "Caf\u{00e9}-0118")]); + let decomposed = [ProposalRow::new(0, "20260812", "Cafe\u{0301}-0118") + .party("Bravo Industries") + .rows(vec![ + ["Bravo Industries", "-4200.00"], + ["Sales Account", "4200.00"], + ]) + .build()]; + let report = run( + &composed, + &catalog(), + &numbering(NumberingMethod::Manual), + &decomposed, + ); + assert_eq!( + only(&report).present_book_key(), + None, + "Unicode composition is part of a voucher number until Tally proves otherwise" + ); +} + +#[test] +fn a_manual_number_does_not_decide_across_differently_spelled_voucher_types() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .voucher_type("sales") + .build()]; + let declaration = + NumberingDeclaration::new([("sales", NumberingMethod::Manual)]).expect("numbering"); + let report = run(&window, &catalog(), &declaration, &proposals); + assert!(!only(&report).voucher_type_observed); + assert_eq!(only(&report).present_book_key(), None); + assert_eq!( + reason(only(&report)), + UndecidedReason::VoucherTypeNotObserved + ); +} + +/// Under a `Manual` declaration the number is the one key that can decide, so +/// a proposal supplying none has offered nothing decisive — an absence would +/// rest on date, party and amount, which this contract does not let decide. +#[test] +fn a_manual_type_without_a_number_cannot_be_reported_absent() { + let window = window(&[BookRow::new("book-1", "20260819", "AA0130").party("Bravo Industries")]); + let mut proposal = ProposalRow::new(0, "20260812", "AA0999"); + proposal.number = None; + proposal = proposal.party("Charlie Minerals").rows(vec![ + ["Charlie Minerals", "-77.00"], + ["Sales Account", "77.00"], + ]); + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &[proposal.build()], + ); + let entry = only(&report); + assert!(!entry.is_absent()); + assert_eq!(reason(entry), UndecidedReason::ManualNumberNotSupplied); +} + +#[test] +fn an_automatic_type_without_a_number_is_still_answerable() { + // Under automatic numbering the number was never decisive, so omitting it + // skips nothing and the absence stands on the rules that could run. + let window = window(&[BookRow::new("book-1", "20260819", "AA0130").party("Bravo Industries")]); + let mut proposal = ProposalRow::new(0, "20260812", "AA0999"); + proposal.number = None; + proposal = proposal.party("Charlie Minerals").rows(vec![ + ["Charlie Minerals", "-77.00"], + ["Sales Account", "77.00"], + ]); + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Automatic), + &[proposal.build()], + ); + assert!(only(&report).is_absent()); +} + +/// The observations sit outside the paged rows, so a consumer cannot trim +/// them. An unbounded echo there can push a complete report past a byte budget +/// that trimming rows could no longer rescue. +#[test] +fn book_observation_labels_are_bounded() { + let long: &'static str = Box::leak( + "N".repeat(MAX_OBSERVATION_LABEL_CHARS + 50) + .into_boxed_str(), + ); + let window = window(&[ + BookRow::new("book-1", "20260812", long), + BookRow::new("book-2", "20260813", long).party("Bravo Industries"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let group = &report.observations().duplicate_numbers[0]; + assert_eq!( + group.voucher_number.chars().count(), + MAX_OBSERVATION_LABEL_CHARS + 1, + "the whole bound of content, plus the marker that says it was applied" + ); + assert!(group.voucher_number.ends_with(SHORTENED)); + // The group's identity is its keys, which are bounded by count, not by the + // label that helps a human recognise it. + assert_eq!(group.book_keys, vec!["book-1", "book-2"]); + assert_eq!(group.book_voucher_count, 2); +} + +/// This contract's two production files are pinned in the compatibility +/// surface, and there is a way for that to stop being true **silently**. +/// +/// Resolving a surface conflict by taking the base side — which is the only +/// correct way to resolve a generated artifact — drops the entries a branch +/// *adds*, because `rehash-surface` updates hashes and never adds paths. The +/// compatibility gate does not catch it: its bound is +/// `MAX_SURFACE_FILES - files.len() <= RESERVED_SURFACE_FILES`, which asserts +/// there is no unreviewed *headroom* rather than that the cap matches the +/// surface. A guard on the slack cannot catch a claim made too early, or a pin +/// quietly lost. +/// +/// So the claim is asserted here instead, in a file that is not itself pinned. +/// If a rebase ever drops these two, this fails loudly rather than the seal +/// passing over a surface that no longer covers the engine it was raised for. +#[test] +fn this_contracts_files_are_still_pinned_in_the_compatibility_surface() { + const SURFACE: &str = + include_str!("../../../../docs/tally/compatibility/compatibility-surface.json"); + let surface: serde_json::Value = serde_json::from_str(SURFACE).expect("surface json"); + let pinned = surface["files"] + .as_array() + .expect("files") + .iter() + .filter_map(|entry| entry["path"].as_str()) + .collect::>(); + for path in [ + "src-tauri/crates/bridge-tally-core/src/book_presence.rs", + "src-tauri/src/agent_presence.rs", + // The adapter reads its bounds from the published schema rather than + // restating them, so the only independent statement of the admission + // contract is the assertion in this file. Unpinned, a loosened schema + // and its matching test update leave the digest untouched. + "src-tauri/src/agent_presence_tests.rs", + ] { + assert!( + pinned.contains(path), + "{path} is no longer pinned: a conflict resolution dropped it and the gate cannot see that" + ); + } +} + +/// `unmatched_book_vouchers` promises to count rows no proposal matched **or +/// even resembled**. A collision returns before rule three, so without help it +/// would report a row this proposal plainly resembles as one nothing came +/// near — the diagnostic contradicting itself. +#[test] +fn a_collision_still_counts_what_the_proposal_resembled() { + let window = window(&[BookRow::new("book-1", "20260812", "AA0118")]); + let money = vec![ + ["Alpha Traders", "-11800.00"], + ["Sales Account", "10000.00"], + ["Output CGST 9%", "900.00"], + ["Output SGST 9%", "900.00"], + ]; + // Two proposals share a REMOTEID the book does not carry, so the collision + // decides — but both plainly resemble book-1 on date, party and amount. + let proposals = [ + ProposalRow::new(0, "20260812", "AA0901") + .remote_id("tally-9") + .rows(money.clone()) + .build(), + ProposalRow::new(1, "20260812", "AA0902") + .remote_id("tally-9") + .rows(money) + .build(), + ]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + for entry in report.vouchers() { + assert_eq!(reason(entry), UndecidedReason::RemoteIdCollision); + } + assert_eq!( + report.observations().unmatched_book_vouchers, + 0, + "book-1 was resembled by both proposals, whatever decided them" + ); +} + +/// A key is an identity, so a pathological one is refused rather than cut — +/// half a key joins to nothing. It is also echoed in every candidate, and a +/// consumer's framing can drop whole rows but cannot shrink one. +#[test] +fn a_pathological_book_key_is_refused_rather_than_truncated() { + let rows = [["Alpha Traders", "-1.00"], ["Sales Account", "1.00"]]; + let entries = entries(&rows); + let long: String = "g".repeat(MAX_BOOK_KEY_CHARS + 1); + assert_eq!( + BookVoucher::observed(ObservedVoucher { + key: &long, + date: "20260812", + voucher_type: "Sales", + voucher_number: Some("AA0118"), + remote_id: None, + party: Some("Alpha Traders"), + entries: &entries, + cancelled: false, + optional: false, + }) + .expect_err("pathological key"), + PresenceError::VoucherKeyTooLong + ); + // A real Tally GUID — company prefix plus master id — is far inside it. + assert!(BookVoucher::observed(ObservedVoucher { + key: "61c6de69-1748-461c-ad3f-162cb949df9f-00000001", + date: "20260812", + voucher_type: "Sales", + voucher_number: Some("AA0118"), + remote_id: None, + party: Some("Alpha Traders"), + entries: &entries, + cancelled: false, + optional: false, + }) + .is_ok()); +} + +/// Candidate order is part of the contract, so the same book must yield the +/// same twenty-five whatever order Tally happened to return its rows in. The +/// cap is applied after ranking, never to an arbitrary source prefix. +#[test] +fn a_capped_collision_list_does_not_depend_on_the_rows_arriving_order() { + let keys: Vec<&'static str> = (1..=40) + .map(|index| Box::leak(format!("book-{index:03}").into_boxed_str()) as &'static str) + .collect(); + let listed = |order: Vec<&'static str>| { + let rows: Vec = order + .into_iter() + .enumerate() + .map(|(offset, key)| { + BookRow::new( + key, + if offset % 2 == 0 { + "20260812" + } else { + "20260813" + }, + "AA0118", + ) + }) + .collect(); + let window = window(&rows); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let undecided = only(&report).undecided().expect("undecided").clone(); + assert_eq!(undecided.reason, UndecidedReason::BookNumberCollision); + assert_eq!(undecided.candidate_count, 40); + undecided + .candidates + .iter() + .map(|candidate| candidate.book_key.clone()) + .collect::>() + }; + let ascending = listed(keys.clone()); + let reversed = listed(keys.into_iter().rev().collect()); + assert_eq!(ascending.len(), MAX_CANDIDATES_PER_PROPOSAL); + assert_eq!( + ascending, reversed, + "the same book must expose the same candidates whatever order its rows arrive in" + ); + // And the retained slice is the ordered prefix, not an arbitrary one. + let mut sorted = ascending.clone(); + sorted.sort(); + assert_eq!(ascending, sorted); +} + +/// A proposal that settles by identity still *reached* whatever else it +/// resembles. `unmatched_book_vouchers` counts only what no proposal came +/// near, so a resembled row must not appear there because another row +/// happened to carry the identity. +#[test] +fn an_identity_match_still_counts_what_it_resembled() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118"), + // Same date, party and amount, different number: resembled, not matched. + BookRow::new("book-2", "20260812", "AA0777"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(only(&report).present_book_key(), Some("book-1")); + assert_eq!( + report.observations().unmatched_book_vouchers, + 0, + "book-2 was resembled even though book-1 carried the identity" + ); +} + +/// The echoed party names are diagnostics a person reads, and a response can +/// drop whole rows but cannot shrink one. The comparison that produced the +/// difference used the full values; only the echo is bounded. +#[test] +fn an_echoed_party_difference_is_bounded() { + let long: &'static str = Box::leak( + format!("Bravo {}", "o".repeat(MAX_OBSERVATION_LABEL_CHARS + 40)).into_boxed_str(), + ); + let names = [ + "Alpha Traders", + long, + "Sales Account", + "Output CGST 9%", + "Output SGST 9%", + ]; + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").party_field(long)]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog_of(&names), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + let party = differences + .iter() + .find(|difference| difference.field == DifferenceField::Party) + .expect("party difference"); + assert_eq!( + party.observed.as_deref().map(|value| value.chars().count()), + // The bound, plus the one character that says it was applied. + Some(MAX_OBSERVATION_LABEL_CHARS + 1) + ); +} + +/// The marker must not cost a character of content. +/// +/// Spending one to stay inside the bound would make two values differing at +/// exactly the bound serialize identically — converting a difference that was +/// visible before the marker existed into one that is not. That is the failure +/// the marker exists to prevent, reintroduced one position earlier, and it +/// would be quieter than the bug it replaced: the report would still say the +/// two differ, and now also say it had shortened them, while showing one +/// string. Both are true statements and the reader still cannot see it. +#[test] +fn the_shortening_marker_does_not_cost_a_character_of_content() { + let shared = "o".repeat(MAX_OBSERVATION_LABEL_CHARS - 1); + // Identical for the whole bound but the final character inside it. + let proposed: &'static str = Box::leak(format!("{shared}A tail").into_boxed_str()); + let observed: &'static str = Box::leak(format!("{shared}B tail").into_boxed_str()); + let names = [ + "Alpha Traders", + proposed, + "Sales Account", + "Output CGST 9%", + "Output SGST 9%", + ]; + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").party_field(observed)]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .party(proposed) + .build()]; + let report = run( + &window, + &catalog_of(&names), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + let party = differences + .iter() + .find(|difference| difference.field == DifferenceField::Party) + .expect("party difference"); + assert_ne!( + party.proposed, party.observed, + "a difference inside the bound must still be visible in the echo" + ); + for shown in [&party.proposed, &party.observed] { + assert!(shown + .as_deref() + .is_some_and(|value| value.ends_with(SHORTENED))); + } +} + +/// Bounding must not quietly turn a true difference into a false display. +/// +/// Two accepted names can agree for the whole bounded prefix and differ after +/// it -- the adapter admits names eight times longer than this bound. The +/// comparison sees the difference, so a difference is reported; without a +/// marker both sides then serialize to the same string and the report asserts +/// that two identical values differ. The marker cannot recover the missing +/// tail, but it stops the report from lying about what it is showing. +#[test] +fn a_difference_bounded_on_both_sides_says_the_values_were_shortened() { + let shared = "Bravo ".to_string() + &"o".repeat(MAX_OBSERVATION_LABEL_CHARS); + let proposed: &'static str = Box::leak(format!("{shared} Northern Division").into_boxed_str()); + let observed: &'static str = Box::leak(format!("{shared} Southern Division").into_boxed_str()); + let names = [ + "Alpha Traders", + proposed, + "Sales Account", + "Output CGST 9%", + "Output SGST 9%", + ]; + let window = window(&[BookRow::new("book-1", "20260812", "AA0118").party_field(observed)]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118") + .party(proposed) + .build()]; + let report = run( + &window, + &catalog_of(&names), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let PresenceStatus::Present { differences, .. } = &only(&report).status else { + panic!("expected Present"); + }; + let party = differences + .iter() + .find(|difference| difference.field == DifferenceField::Party) + .expect("the full values differ, so a difference is reported"); + let shown_proposed = party.proposed.as_deref().expect("proposed"); + let shown_observed = party.observed.as_deref().expect("observed"); + // The premise: bounding really does collapse these two onto one string. + assert_eq!( + shown_proposed, shown_observed, + "the values agree across the whole bounded prefix" + ); + for shown in [shown_proposed, shown_observed] { + assert!( + shown.ends_with('\u{2026}'), + "a shortened value must say it was shortened" + ); + assert_eq!( + shown.chars().count(), + MAX_OBSERVATION_LABEL_CHARS + 1, + "the whole bound of content, plus the marker" + ); + } +} + +/// A collision returns before resemblance can decide anything, but the +/// proposal still *reached* what it resembles. Every earlier collision test +/// had the whole window sharing the number, so the colliding set and the +/// resembled set were the same rows and a bare set looked correct. +#[test] +fn a_number_collision_still_reaches_what_it_only_resembled() { + let window = window(&[ + BookRow::new("book-1", "20260812", "AA0118"), + // Shares the number: a collision, and the reason this returns early. + BookRow::new("book-2", "20260812", "AA0118"), + // Shares date, party and amount but not the number: resembled only, + // and reachable solely through the resemblance scan the early return + // used to skip. + BookRow::new("book-3", "20260812", "AA0777"), + ]); + let proposals = [ProposalRow::new(0, "20260812", "AA0118").build()]; + let report = run( + &window, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert_eq!(reason(only(&report)), UndecidedReason::BookNumberCollision); + assert_eq!( + report.observations().unmatched_book_vouchers, + 0, + "book-3 was plainly resembled; the collision must not hide that" + ); +} + +#[test] +fn raw_proposal_budget_counts_all_entry_work_before_conversion() { + let entries = vec![ + ObservedEntry { + ledger: "L", + amount: "1" + }; + 2_000 + ]; + let inputs = (0..50) + .map(|position| ProposedVoucherInput { + position, + date: "20260812", + voucher_type: "Receipt", + voucher_number: Some("1"), + remote_id: None, + party: None, + entries: &entries, + }) + .collect::>(); + let admitted = + ProposedVoucher::from_inputs(inputs.iter().copied()).expect("exact 100,000 entries"); + assert_eq!(admitted.as_slice().len(), 50); + let extra = [ObservedEntry { + ledger: "L", + amount: "not-an-amount", + }]; + let next = ProposedVoucherInput { + position: 50, + entries: &extra, + ..inputs[0] + }; + assert_eq!( + inputs.iter().map(|v| v.entries.len()).sum::() + next.entries.len(), + MAX_PROPOSAL_RAW_ENTRY_WORK + 1 + ); + assert_eq!( + ProposedVoucher::from_inputs(inputs.into_iter().chain([next])), + Err(PresenceError::ProposalRawEntryWorkTooLarge) + ); +} + +#[test] +fn raw_proposal_budget_counts_metadata_bytes_before_conversion() { + let rows = [ObservedEntry { + ledger: "L", + amount: "1", + }]; + let metadata = "x".repeat(16_384 - "20260812".len() - "Receipt".len() - 1); + let extra_byte = format!("{metadata}x"); + assert!(extra_byte.len() <= MAX_TEXT_CHARS); + let inputs = (0..256) + .map(|position| ProposedVoucherInput { + position, + date: "20260812", + voucher_type: "Receipt", + voucher_number: Some("1"), + remote_id: None, + party: Some(metadata.as_str()), + entries: &rows, + }) + .collect::>(); + let total = inputs + .iter() + .map(|v| { + v.date.len() + + v.voucher_type.len() + + v.voucher_number.unwrap().len() + + v.party.unwrap().len() + }) + .sum::(); + assert_eq!(total, MAX_PROPOSAL_RAW_BYTES); + assert_eq!( + ProposedVoucher::from_inputs(inputs.iter().copied()) + .expect("exact metadata limit") + .as_slice() + .len(), + 256 + ); + let mut over = inputs; + over[255].party = Some(&extra_byte); + assert_eq!( + total + extra_byte.len() - metadata.len(), + MAX_PROPOSAL_RAW_BYTES + 1 + ); + assert_eq!( + ProposedVoucher::from_inputs(over), + Err(PresenceError::ProposalRawBytesTooLarge) + ); +} + +#[test] +fn raw_observation_budget_counts_retained_voucher_metadata() { + let rows = [ObservedEntry { + ledger: "L", + amount: "1", + }]; + let keys = (0..256) + .map(|position| format!("K{position:07}")) + .collect::>(); + let metadata = "x".repeat(16_384 - 8 - "20260812".len() - "Receipt".len()); + let extra_byte = format!("{metadata}x"); + assert!(extra_byte.len() <= MAX_TEXT_CHARS); + let inputs = keys + .iter() + .map(|key| ObservedVoucher { + key, + date: "20260812", + voucher_type: "Receipt", + voucher_number: None, + remote_id: Some(metadata.as_str()), + party: None, + entries: &rows, + cancelled: false, + optional: false, + }) + .collect::>(); + let total = inputs + .iter() + .map(|v| v.key.len() + v.date.len() + v.voucher_type.len() + v.remote_id.unwrap().len()) + .sum::(); + assert_eq!(total, MAX_WINDOW_RAW_ENTRY_BYTES); + assert_eq!( + BookWindow::from_observations( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + inputs.iter().copied() + ) + .expect("exact metadata limit") + .vouchers() + .len(), + 256 + ); + let mut over = inputs; + over[255].remote_id = Some(&extra_byte); + assert_eq!( + total + extra_byte.len() - metadata.len(), + MAX_WINDOW_RAW_ENTRY_BYTES + 1 + ); + assert_eq!( + BookWindow::from_observations( + "20260801", + "20260831", + WindowRead::Complete, + RemoteIdEvidence::Observed, + over + ), + Err(PresenceError::WindowRawEntryBytesTooLarge) + ); +} + +#[test] +fn raw_proposal_batch_stops_an_unbounded_iterator_at_the_count_limit() { + let rows = [ObservedEntry { + ledger: "L", + amount: "1", + }]; + let seen = std::cell::Cell::new(0); + let inputs = std::iter::from_fn(|| { + let position = seen.get(); + assert!( + position <= MAX_PROPOSED_VOUCHERS, + "must stop after the first excess input" + ); + seen.set(position + 1); + Some(ProposedVoucherInput { + position, + date: "20260812", + voucher_type: "Receipt", + voucher_number: None, + remote_id: None, + party: None, + entries: &rows, + }) + }); + assert_eq!( + ProposedVoucher::from_inputs(inputs), + Err(PresenceError::TooManyProposals) + ); + assert_eq!(seen.get(), MAX_PROPOSED_VOUCHERS + 1); +} + +#[test] +fn proposal_batch_rejects_duplicate_source_positions_before_conversion() { + let rows = [ObservedEntry { + ledger: "L", + amount: "1", + }]; + let inputs = [ + ProposedVoucherInput { + position: 7, + date: "20260812", + voucher_type: "Receipt", + voucher_number: Some("1"), + remote_id: None, + party: None, + entries: &rows, + }, + ProposedVoucherInput { + position: 7, + date: "20260812", + voucher_type: "Receipt", + voucher_number: Some("2"), + remote_id: None, + party: None, + entries: &rows, + }, + ]; + assert_eq!( + ProposedVoucher::from_inputs(inputs), + Err(PresenceError::DuplicateProposalPosition) + ); +} diff --git a/src-tauri/crates/bridge-tally-core/src/lib.rs b/src-tauri/crates/bridge-tally-core/src/lib.rs index 9860d52d..7017843d 100644 --- a/src-tauri/crates/bridge-tally-core/src/lib.rs +++ b/src-tauri/crates/bridge-tally-core/src/lib.rs @@ -8,6 +8,7 @@ pub use bridge_tally_primitives::{ }; pub mod bills_reconciliation; +pub mod book_presence; pub mod master_binding; mod pack_models; pub mod reconciliation; diff --git a/src-tauri/src/agent.rs b/src-tauri/src/agent.rs index e4320201..30d438d4 100644 --- a/src-tauri/src/agent.rs +++ b/src-tauri/src/agent.rs @@ -31,6 +31,8 @@ mod changes; mod ledgers; #[path = "agent_outstandings.rs"] mod outstandings; +#[path = "agent_presence.rs"] +mod presence; #[path = "agent_vouchers.rs"] mod vouchers; #[cfg(test)] @@ -671,6 +673,7 @@ impl Server { "verify_import" => self.verify_import(args).await, "ledger_masters" => self.ledger_masters(args).await, "vouchers" => self.vouchers(args).await, + "voucher_presence" => self.voucher_presence(args).await, "changed_since" => self.changed_since(args).await, "outstandings" => self.outstandings(args).await, "ledger_movement" => self.ledger_movement(args).await, @@ -938,7 +941,10 @@ fn corroborate_empty_voucher_window( return Err("window_contradicted".to_string()); } if !widened_rows.is_empty() { - return Ok((false, None)); + // Boundary-day rows only prove that this wider read returned *some* + // data. They provide no independent cardinality for the nonempty + // response, so they cannot promote the original empty window. + return Ok((true, Some("nonempty_uncorroborated"))); } match company_high_water { Some(0) => Ok((false, Some("company_has_no_vouchers"))), diff --git a/src-tauri/src/agent_admission_tests.rs b/src-tauri/src/agent_admission_tests.rs index 25f5a11c..fb8612bc 100644 --- a/src-tauri/src/agent_admission_tests.rs +++ b/src-tauri/src/agent_admission_tests.rs @@ -1,5 +1,54 @@ use super::*; +#[test] +fn published_pattern_inventory_preserves_the_admitted_wire_shapes() { + let accepted_dates = ["20260901", "2026-09-01", "2026-0901", "202609-01"]; + for date in accepted_dates { + assert!(published_pattern_matches(DATE_WIRE_PATTERN, date), "{date}"); + } + for rejected in ["2-0-2-6-0-9-0-1", "2026/09/01", "2026090", "202609011"] { + assert!( + !published_pattern_matches(DATE_WIRE_PATTERN, rejected), + "{rejected}" + ); + } + assert!(published_pattern_matches( + NONBLANK_PATTERN, + "\u{2003}ledger" + )); + assert!(!published_pattern_matches(NONBLANK_PATTERN, " \u{2003}\t")); + + fn patterns(value: &Value, found: &mut Vec) { + match value { + Value::Object(object) => { + if let Some(pattern) = object.get("pattern").and_then(Value::as_str) { + found.push(pattern.to_string()); + } + for child in object.values() { + patterns(child, found); + } + } + Value::Array(values) => { + for child in values { + patterns(child, found); + } + } + _ => {} + } + } + + let definitions = registered_tool_definitions(true, true); + let schema = definitions + .as_array() + .and_then(|tools| tools.iter().find(|tool| tool["name"] == "voucher_presence")) + .expect("voucher_presence tool"); + let mut found = Vec::new(); + patterns(&schema["inputSchema"], &mut found); + found.sort(); + found.dedup(); + assert_eq!(found, vec![NONBLANK_PATTERN, DATE_WIRE_PATTERN]); +} + #[tokio::test] async fn voucher_type_selector_is_bounded_before_any_tally_read() { let directory = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/agent_catalog.rs b/src-tauri/src/agent_catalog.rs index 8a27aeb4..bdc789e7 100644 --- a/src-tauri/src/agent_catalog.rs +++ b/src-tauri/src/agent_catalog.rs @@ -1,6 +1,9 @@ //! Public tool catalog and argument admission before any Tally read. use super::*; +const NONBLANK_PATTERN: &str = r"\S"; +const DATE_WIRE_PATTERN: &str = "^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"; + pub(super) fn validate_tool_arguments(name: &str, args: &Value) -> Result<(), String> { let arguments = args .as_object() @@ -90,6 +93,84 @@ pub(super) fn validate_tool_arguments(name: &str, args: &Value) -> Result<(), St Ok(()) } +/// Validates a value against a published schema fragment, recursively. +/// +/// [`validate_tool_arguments`] deliberately stops at the outer selectors, +/// because every tool that predates nested inputs owns its own typed boundary +/// below that line and tightening the shared path would change their refusal +/// codes. A tool whose `inputSchema` *does* describe nested objects calls this +/// instead of restating those bounds in its parser: two copies of one bound +/// drift, and the copy that drifts is the one nobody is looking at. +/// +/// It enforces exactly what the fragment states — `type`, `enum`, string +/// bounds and patterns, array bounds, `required`, and `additionalProperties: +/// false` — and nothing it does not, so a schema remains the single +/// description of what a caller may send. +pub(super) fn validate_against_schema( + value: &Value, + schema: &Value, + key: &str, +) -> Result<(), String> { + let invalid = || format!("argument_invalid:{key}"); + if schema["enum"] + .as_array() + .is_some_and(|allowed| !allowed.contains(value)) + { + return Err(invalid()); + } + match schema["type"].as_str() { + Some("string") => { + let text = value.as_str().ok_or_else(invalid)?; + validate_string_bounds(text, schema, key)?; + } + Some("integer") => { + let number = value.as_u64().ok_or_else(invalid)?; + if schema["minimum"].as_u64().is_some_and(|min| number < min) { + return Err(invalid()); + } + } + Some("array") => { + let items = value.as_array().ok_or_else(invalid)?; + if schema["minItems"] + .as_u64() + .is_some_and(|min| items.len() < min as usize) + || schema["maxItems"] + .as_u64() + .is_some_and(|max| items.len() > max as usize) + { + return Err(invalid()); + } + for item in items { + validate_against_schema(item, &schema["items"], key)?; + } + } + Some("object") => { + let object = value.as_object().ok_or_else(invalid)?; + let properties = schema["properties"].as_object(); + if schema["additionalProperties"] == Value::Bool(false) + && object + .keys() + .any(|name| !properties.is_some_and(|properties| properties.contains_key(name))) + { + return Err(invalid()); + } + for required in schema["required"].as_array().into_iter().flatten() { + let name = required.as_str().ok_or_else(invalid)?; + if !object.contains_key(name) { + return Err(invalid()); + } + } + for (name, member) in object { + if let Some(fragment) = properties.and_then(|properties| properties.get(name)) { + validate_against_schema(member, fragment, key)?; + } + } + } + _ => {} + } + Ok(()) +} + fn validate_string_bounds(text: &str, schema: &Value, key: &str) -> Result<(), String> { let length = text.chars().count(); if schema["minLength"] @@ -98,13 +179,47 @@ fn validate_string_bounds(text: &str, schema: &Value, key: &str) -> Result<(), S || schema["maxLength"] .as_u64() .is_some_and(|max| length > max as usize) - || (schema["pattern"] == r"\S" && text.trim().is_empty()) + || schema["pattern"] + .as_str() + .is_some_and(|pattern| !published_pattern_matches(pattern, text)) { return Err(format!("argument_invalid:{key}")); } Ok(()) } +/// Recognize the finite pattern vocabulary in the published local-tool schema. +/// +/// Pattern text is schema authority, but accepting an arbitrary new expression +/// would add an unbounded compile/cache decision to the admission path. Unknown +/// patterns therefore refuse input until their exact wire shape is implemented +/// and reviewed here. Calendar validity stays with `normalized_date` at the +/// typed boundary; this only preserves the published lexical shape. +fn published_pattern_matches(pattern: &str, text: &str) -> bool { + match pattern { + NONBLANK_PATTERN => text.chars().any(|character| !character.is_whitespace()), + DATE_WIRE_PATTERN => { + let bytes = text.as_bytes(); + let Some((year, remainder)) = bytes.split_at_checked(4) else { + return false; + }; + if !year.iter().all(u8::is_ascii_digit) { + return false; + } + let remainder = remainder.strip_prefix(b"-").unwrap_or(remainder); + let Some((month, remainder)) = remainder.split_at_checked(2) else { + return false; + }; + if !month.iter().all(u8::is_ascii_digit) { + return false; + } + let remainder = remainder.strip_prefix(b"-").unwrap_or(remainder); + remainder.len() == 2 && remainder.iter().all(u8::is_ascii_digit) + } + _ => false, + } +} + pub(super) fn tool_definitions(import_enabled: bool, writes_enabled: bool) -> Value { let mut definitions = registered_tool_definitions(import_enabled, writes_enabled); definitions @@ -129,6 +244,7 @@ pub(super) fn registered_tool_definitions(import_enabled: bool, writes_enabled: "ledger_movement", "trial_balance", "vouchers", + "voucher_presence", "changed_since", "read_evidence", "egress_log", @@ -191,6 +307,24 @@ pub(super) fn registered_tool_definitions(import_enabled: bool, writes_enabled: "Return literal-window voucher evidence with curated metadata and redaction. Reads the full source window before selectors and output pagination; limit does not reduce Tally work. Use narrow dates; dense windows are unqualified and can fail source limits.", json!({"type":"object","additionalProperties":false,"required":["company_guid","from","to"],"properties":{"company_guid":{"type":"string","minLength":1},"from":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"},"to":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"},"voucher_type":{"type":"string","maxLength":agent_import::MAX_MASTER_NAME_CHARS},"ledger":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"},"offset":{"type":"integer","minimum":0,"default":0},"limit":{"type":"integer","minimum":1,"default":500}}}), ), + "voucher_presence" => ( + "Answer which of 1\u{2013}500 proposed vouchers are already in the book. `presence` is present, possibly_present or absent, and only `present` names a book voucher. The adapter has no source-completeness evidence for a nonempty window, so a nonempty window is read as `partial`; an empty window can still be corroborated complete. `present` and `possibly_present` never need a complete window and are produced either way, but `absent` means absent from the *whole* window and is only ever produced from one proven complete — a proposal that would otherwise be absent from a merely `partial` window instead comes back `possibly_present` with reason `window_not_proven_complete`. The conditional decision basis can use a voucher number on a voucher type you declare `manual` \u{2014} unique on both sides, within an observed voucher type, and never onto a cancelled or optional voucher. It neither accepts nor reads client remote identifiers. Date, party and amount only ever produce candidates, with the rule that surfaced each and no ranking or score. Every voucher type a proposal names needs a declared numbering method; under `automatic` Tally discards the supplied number, so nothing can be decided from it. `absent` means absent from this window, so cover the dates the book could hold. Reads the full window before comparing; dense windows can fail source limits. Party names bind through the same rules as validate_masters. A reported difference on a `present` voucher is a finding for a person, not a work item: correcting a voucher by Alter or Cancel silently creates a duplicate instead (\u{00a7}9.7), and no Bridge path can correct a voucher it did not write. This never dispatches import XML to Tally.", + json!({"type":"object","additionalProperties":false,"required":["company_guid","from","to","numbering","vouchers"],"properties":{ + "company_guid":{"type":"string","minLength":1}, + "offset":{"type":"integer","minimum":0,"default":0}, + "limit":{"type":"integer","minimum":1,"default":500}, + "from":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"}, + "to":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"}, + "numbering":{"type":"array","minItems":1,"maxItems":presence::MAX_PRESENCE_VOUCHER_TYPES,"items":{"type":"object","additionalProperties":false,"required":["voucher_type","numbering_method"],"properties":{"voucher_type":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"},"numbering_method":{"type":"string","enum":["manual","automatic","unknown"]}}}}, + "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"}}}} + }}} + }}), + ), "changed_since" => ( "Return snapshot-pinned AlterID voucher and master evidence. Continue a truncated scan with both returned AlterID cursors and snapshot values; deletion detection remains unsupported.", json!({"type":"object","additionalProperties":false,"required":["company_guid"],"properties":{"company_guid":{"type":"string","minLength":1},"voucher_alter_id":{"type":"integer","minimum":0,"default":0},"master_alter_id":{"type":"integer","minimum":0,"default":0},"voucher_snapshot_alter_id":{"type":"integer","minimum":0},"master_snapshot_alter_id":{"type":"integer","minimum":0}}}), diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs new file mode 100644 index 00000000..f6d164e2 --- /dev/null +++ b/src-tauri/src/agent_presence.rs @@ -0,0 +1,553 @@ +//! "Which of these are already in the book?" for the local MCP adapter. +//! +//! The rules live in `bridge_tally_core::book_presence` so this tool and any +//! later desktop screen cannot drift apart; see +//! `docs/adr/0017-voucher-presence-authority.md`. This file owns only the two +//! qualified reads that produce the evidence, the typed parse of the caller's +//! proposals, and the response shape. +use super::*; +use std::collections::BTreeSet; + +use bridge_tally_core::book_presence::{ + self, BookWindow, NumberingDeclaration, NumberingMethod, ObservedEntry, ObservedVoucher, + PresenceError, PresenceReport, PresenceRequest, ProposedVoucher, ProposedVoucherInput, + RawObservationBudget, RemoteIdEvidence, WindowRead, +}; +use bridge_tally_core::master_binding::{MasterCatalog, MasterClass, SourceEntity}; + +/// Most vouchers one presence request may propose. The window read is +/// unaffected by this: it always reads its whole range. +pub(super) const MAX_PRESENCE_VOUCHERS: usize = 500; +/// Most voucher types one numbering declaration may name. +pub(super) const MAX_PRESENCE_VOUCHER_TYPES: usize = 50; +/// Share of the byte cap the fixed observations may occupy. +/// +/// `fit_response` can trim only `items`; `book` is a sibling it cannot reach, +/// and the final framing serializes the whole payload **twice** -- once as +/// `structuredContent` and again as text for clients that read only that. So a +/// maximal `book` (twenty-five duplicate-number groups of ten 128-character +/// keys, twenty-five unbalanced keys, labels at their bound) runs to six +/// figures on its own, and the doubled envelope clears the default cap without +/// a single large item. The report would then be discarded wholesale *after* +/// all three Tally reads were paid for. +/// +/// An eighth leaves the doubled observations at a quarter of the cap. +const OBSERVATION_BUDGET_DIVISOR: usize = 8; +/// Most ledger entries one proposed voucher may carry. +pub(super) const MAX_PRESENCE_ENTRIES: usize = 200; +/// Enforces the published `inputSchema` on this tool's nested arrays. +/// +/// The shared argument validator stops at the outer selectors, and the core +/// crate's own limits are far wider than this tool advertises, so the gap has +/// to be closed somewhere. Closing it by restating the bounds in this parser +/// would put two copies of every limit in the tree; driving it from the schema +/// itself keeps one. +fn enforce_published_schema(args: &Value) -> Result<(), String> { + let definitions = catalog::registered_tool_definitions(true, true); + let schema = definitions + .as_array() + .and_then(|tools| tools.iter().find(|tool| tool["name"] == "voucher_presence")) + .map(|tool| tool["inputSchema"].clone()) + .ok_or_else(|| "tool_not_found".to_string())?; + for key in ["numbering", "vouchers"] { + if let Some(value) = args.get(key) { + catalog::validate_against_schema(value, &schema["properties"][key], key)?; + } + } + Ok(()) +} + +impl Server { + pub(super) async fn voucher_presence(&self, args: &Value) -> Result { + let guid = required_string(args, "company_guid")?; + let from = normalized_date(required_string(args, "from")?)?; + let to = normalized_date(required_string(args, "to")?)?; + if from > to { + return Err("invalid_date_range".to_string().into()); + } + // Parse the caller's own input before any Tally read: a malformed + // proposal set should never cost a read. + enforce_published_schema(args)?; + let offset = arg_usize(args, "offset", 0)?; + let limit = + arg_positive_usize(args, "limit", self.settings.max_rows)?.min(self.settings.max_rows); + let numbering = parse_numbering(args)?; + let proposals = parse_proposals(args)?; + // Both remaining cross-input refusals depend only on the arguments, so + // they are settled here rather than after three Tally reads. The crate + // enforces them again at its own boundary; this only stops a request + // that was always going to be refused from exercising the endpoint. + for proposal in proposals.iter() { + // A party's *entity shape* -- how many identifiers its name + // carries -- is decided entirely by the caller's text, and the + // crate parses it inside `PresenceRequest::new`, three reads + // later. Parsing it here keeps the promise the refusal path + // already makes everywhere else: an input this tool was always + // going to reject costs no Tally read. + if let Some(party) = proposal.party() { + SourceEntity::new(proposal.position(), party) + .map_err(|error| error.safe_reason_code().to_string())?; + } + if proposal.date() < from.as_str() || proposal.date() > to.as_str() { + return Err(PresenceError::WindowDoesNotCover + .safe_reason_code() + .to_string() + .into()); + } + if !numbering.declares(proposal.voucher_type()) { + return Err(PresenceError::NumberingMethodUndeclared + .safe_reason_code() + .to_string() + .into()); + } + } + + let (company, identity, accumulated) = self.verified_company(guid).await?; + let mut accumulated = Some(accumulated); + let outcome = async { + let (catalogue, catalogue_evidence) = + self.read_ledger_catalogue(&identity, &company.name).await?; + accumulate(&mut accumulated, catalogue_evidence); + let catalog = MasterCatalog::new(MasterClass::Ledger, &catalogue) + .map_err(|error| error.safe_reason_code().to_string())?; + + let request = render_agent_vouchers(&company.name, &from, &to, None)?; + let (xml, evidence) = self.post_read(&identity, request).await?; + accumulate(&mut accumulated, evidence); + let rows = validate_then_filter_voucher_rows( + parse_agent_rows(&xml, identity.company_guid())?, + &from, + &to, + None, + )?; + + // The window is independent evidence about which ledgers exist. + // A row posting to an unlisted ledger proves the first catalogue + // short, regardless of whether a later window qualification could + // have authorised a verdict. Refuse before the nonempty hold so + // this distinct source defect remains visible without a redundant + // paired catalogue read. + for row in &rows { + let entry_ledgers = row["amounts"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(|entry| entry["ledger"].as_str()); + for ledger in present_text(&row["party"]) + .into_iter() + .chain(entry_ledgers.filter(|name| !name.trim().is_empty())) + { + if catalog.exact(ledger).is_none() { + return Err("ledger_catalogue_incomplete".to_string().into()); + } + } + } + + // A window can only license `Absent` when its cardinality is + // independently established. The existing empty-window control + // can establish that narrow case. A nonempty response has no + // source-side count, so a well-formed bounded response cannot be + // promoted to Complete merely because it contains rows. + let mut read = WindowRead::Partial; + let mut reason = Some("nonempty_window_unqualified"); + if rows.is_empty() { + let (read_evidence, partial, corroboration) = self + .corroborate_empty_voucher_read(&identity, &company.name, &from, &to, None) + .await?; + accumulate(&mut accumulated, read_evidence); + reason = corroboration; + if partial { + read = WindowRead::Partial; + if let Some(evidence) = accumulated.as_mut() { + evidence.state = "partial"; + evidence.reason_code = corroboration.map(str::to_string); + } + } else { + read = WindowRead::Complete; + } + } else if let Some(evidence) = accumulated.as_mut() { + evidence.state = "partial"; + evidence.reason_code = reason.map(str::to_string); + // The adapter has no source-side cardinality for nonempty + // windows, so `read` stays `Partial` (its default above) and + // this window can never license `Absent` (`book_presence` + // degrades that to `WindowNotProvenComplete` instead of + // refusing it). `Present` and `PossiblyPresent` need no + // completeness proof, so the read continues to the + // paired-snapshot checks below rather than refusing outright. + } + + // The verdict is built from two independently timed observations, + // so the catalogue must still be the one the parties bound + // against. A ledger renamed between the reads would otherwise let + // a proposal bind an old name while the rows carry the new one, + // removing the only resemblance and manufacturing an `absent`. + // Same paired-snapshot rule the selected-voucher read applies. + let (corroboration, corroboration_evidence) = + self.read_ledger_catalogue(&identity, &company.name).await?; + accumulate(&mut accumulated, corroboration_evidence); + let before = catalogue + .iter() + .map(String::as_str) + .collect::>(); + let after = corroboration + .iter() + .map(String::as_str) + .collect::>(); + if before.len() != catalogue.len() + || after.len() != corroboration.len() + || before != after + { + return Err("ledger_snapshot_drifted".to_string().into()); + } + + // The qualified `vouchers` profile does not FETCH REMOTEID, so an + // absent value here means "never read", not "the voucher has + // none". Declaring that keeps a proposal whose own REMOTEID was + // never compared out of `absent`. + let window = book_window(&from, &to, read, &rows).map_err(presence_code)?; + let request = PresenceRequest::new(&window, &catalog, &numbering, &proposals) + .map_err(presence_code)?; + let report = book_presence::assess(&request); + let (result, truncated) = presence_result( + &report, + &catalogue, + read, + reason, + offset, + limit, + self.settings.max_bytes, + ); + + Ok(ToolOutcome { + payload: json!({ + "company": company_json(&company, std::slice::from_ref(&company)), + "result": result, + }), + evidence: accumulated + .clone() + .expect("presence evidence is present after admitted reads"), + company_guid: Some(guid.to_string()), + truncated, + }) + } + .await; + outcome.map_err(|failure: ToolFailure| match accumulated { + Some(evidence) => failure.with_prior_evidence(evidence), + None => failure, + }) + } +} + +fn accumulate(target: &mut Option, next: Evidence) { + *target = Some(match target.take() { + Some(current) => combine_evidence(current, next), + None => next, + }); +} + +fn presence_code(error: PresenceError) -> ToolFailure { + error.safe_reason_code().to_string().into() +} + +/// Turns one validated voucher row from the qualified window read into an +/// observed book voucher. `REMOTEID` is deliberately not read here: the +/// `vouchers` profile does not fetch it, and inventing an absent column would +/// be worse than reporting that it was never observed. +fn book_window( + from: &str, + to: &str, + read: WindowRead, + rows: &[Value], +) -> Result { + let mut budget = RawObservationBudget::default(); + let mut entries = 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(), + ) + }), + )?; + entries.push( + row["amounts"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .map(|entry| ObservedEntry { + ledger: entry["ledger"].as_str().unwrap_or_default(), + amount: entry["amount"].as_str().unwrap_or_default(), + }) + .collect::>(), + ); + } + 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) +} + +/// Reads an optional text field the way the JSON above actually spells absence. +/// +/// A voucher with no party carries `"party": ""`, not a missing key, so +/// `as_str()` yields `Some("")` -- which claims the voucher *has* a party whose +/// name is blank. Two separate guards then fired on a book that simply had a +/// voucher naming nobody: a catalogue lookup reported the whole 864-master book +/// incomplete, and `presence_text_blank` refused the window outright. +/// +/// Only the **observed** side uses this. A blank on a caller's proposal is input +/// the schema already refuses, and translating that to "absent" here would +/// quietly accept what the schema rejects. +fn present_text(value: &Value) -> Option<&str> { + value.as_str().filter(|text| !text.trim().is_empty()) +} + +fn parse_numbering(args: &Value) -> Result { + let declared = args + .get("numbering") + .and_then(Value::as_array) + .ok_or_else(|| "numbering_required".to_string())?; + let entries = declared + .iter() + .map(|entry| { + let voucher_type = entry["voucher_type"] + .as_str() + .ok_or_else(|| "argument_invalid:numbering".to_string())? + .to_string(); + let method = match entry["numbering_method"].as_str() { + Some("manual") => NumberingMethod::Manual, + Some("automatic") => NumberingMethod::Automatic, + // The schema admits exactly these three, so anything else was + // already refused above. + _ => NumberingMethod::Unknown, + }; + Ok((voucher_type, method)) + }) + .collect::, String>>()?; + NumberingDeclaration::new(entries).map_err(|error| error.safe_reason_code().to_string()) +} + +fn parse_proposals( + args: &Value, +) -> Result { + let proposed = args + .get("vouchers") + .and_then(Value::as_array) + .ok_or_else(|| "vouchers_required".to_string())?; + let invalid = || "argument_invalid:vouchers".to_string(); + struct RawProposal { + date: String, + voucher_type: String, + voucher_number: Option, + party: Option, + entries: Vec<(String, String)>, + } + let mut raw = Vec::with_capacity(proposed.len().min(book_presence::MAX_PROPOSED_VOUCHERS)); + let mut admission = bridge_tally_core::book_presence::RawProposalBudget::default(); + for (position, voucher) in proposed.iter().enumerate() { + let raw_date = voucher["date"].as_str().ok_or_else(invalid)?; + let raw_type = voucher["voucher_type"].as_str().ok_or_else(invalid)?; + let rows = voucher["entries"].as_array().ok_or_else(invalid)?; + // Admit the complete borrowed shape before date/decimal parsing or + // cloning any proposal metadata. The shared core repeats this check + // for callers that do not use the JSON adapter. + admission + .admit_parts( + position, + raw_date, + raw_type, + voucher["voucher_number"].as_str(), + None, + voucher["party"].as_str(), + rows.iter().map(|entry| { + ( + entry["ledger"].as_str().unwrap_or_default(), + entry["amount"].as_str().unwrap_or_default(), + ) + }), + ) + .map_err(|error| error.safe_reason_code().to_string())?; + let date = normalized_date(raw_date)?; + let entries = rows + .iter() + .map(|entry| { + Ok(( + entry["ledger"].as_str().ok_or_else(invalid)?.to_string(), + entry["amount"].as_str().ok_or_else(invalid)?.to_string(), + )) + }) + .collect::, String>>()?; + raw.push(RawProposal { + date, + voucher_type: voucher["voucher_type"] + .as_str() + .ok_or_else(invalid)? + .to_string(), + voucher_number: voucher["voucher_number"].as_str().map(str::to_string), + party: voucher["party"].as_str().map(str::to_string), + entries, + }); + } + // Materialize entry descriptors so their borrowed slices outlive the + // batch conversion; admission still precedes decimal parsing and clones. + let descriptors: Vec>> = raw + .iter() + .map(|voucher| { + voucher + .entries + .iter() + .map(|(ledger, amount)| ObservedEntry { ledger, amount }) + .collect() + }) + .collect(); + let inputs = raw + .iter() + .enumerate() + .map(|(position, voucher)| ProposedVoucherInput { + position, + date: &voucher.date, + voucher_type: &voucher.voucher_type, + voucher_number: voucher.voucher_number.as_deref(), + remote_id: None, + party: voucher.party.as_deref(), + entries: &descriptors[position], + }); + ProposedVoucher::from_inputs(inputs).map_err(|error| error.safe_reason_code().to_string()) +} + +/// Bounds the fixed observations, so a diagnostic can never cost the answer. +/// +/// The counts are what a person acts on; the listed keys are a convenience for +/// finding the rows again. When the listing will not fit, the listing goes and +/// every count stays -- and the report says so, because a list that is shorter +/// than it claims is the defect this contract keeps finding elsewhere. +fn bounded_observations(mut book: Value, budget: usize) -> Value { + // Drop one listed row at a time rather than the whole listing. Twenty of + // twenty-five duplicate groups is worth more to the person reading this + // than none of them, and the counts beside them stay exact either way. + let mut withheld = false; + while book.to_string().len() > budget { + let dropped = book["duplicate_numbers"] + .as_array_mut() + .and_then(Vec::pop) + .inspect(|_| book["duplicate_numbers_truncated"] = json!(true)) + .or_else(|| { + book["unbalanced_vouchers"] + .as_array_mut() + .and_then(Vec::pop) + }); + if dropped.is_none() { + // Only counts and flags are left; they are the part a reader + // reconciles against, so they are never dropped. + break; + } + withheld = true; + } + if withheld { + book["listings_withheld_for_size"] = json!(true); + } + book +} + +fn presence_result( + report: &PresenceReport, + catalogue: &[String], + read: WindowRead, + corroboration_reason: Option<&'static str>, + offset: usize, + limit: usize, + max_bytes: usize, +) -> (Value, bool) { + let (from, to) = report.window(); + let total = report.vouchers().len(); + let read_label = match read { + WindowRead::Complete => "complete", + WindowRead::Partial => "partial", + }; + // Paged like every other read in this adapter, for one reason beyond + // consistency: this result shape is otherwise invisible to `page_shape`, + // so an over-large report would be discarded wholesale *after* all three + // Tally reads were paid for. An `items` array with an `offset` is the + // shape the response machinery can trim with a resumable cursor. + let items = report + .vouchers() + .iter() + .skip(offset) + .take(limit) + .map(|entry| mark_presence_party_names(serde_json::to_value(entry).unwrap_or_default())) + .collect::>(); + let next_offset = offset.saturating_add(items.len()); + let truncated = next_offset < total; + let mut result = json!({ + "profile": "agent_voucher_presence_v1", + // Every verdict is relative to this window. `absent` means absent from + // this range and never absent from the book, and it is only ever + // produced when `read` here is "complete" -- a "partial" window still + // yields `present`/`possibly_present`, just never `absent`. + "window": {"from": from, "to": to, "read": read_label, "reason": corroboration_reason}, + "items": items, + "offset": offset, + "total": total, + "totals": report.totals(), + "book": bounded_observations( + serde_json::to_value(report.observations()).unwrap_or_default(), + max_bytes / OBSERVATION_BUDGET_DIVISOR, + ), + "catalogue_evidence_sha256": sha256_json(&catalogue.to_vec()), + }); + if truncated { + result["next_offset"] = json!(next_offset); + } + (result, truncated) +} + +/// Marks the names an egress policy treats as party data. Voucher numbers and +/// dates are accounting selectors the sibling voucher read already emits +/// unmarked; the names are not. +pub(super) fn mark_presence_party_names(mut entry: Value) -> Value { + if let Some(party) = entry.get_mut("party") { + mark_party_field(party, "catalog_name"); + } + if let Some(differences) = entry.get_mut("differences").and_then(Value::as_array_mut) { + for difference in differences { + if difference.get("field").and_then(Value::as_str) != Some("party") { + continue; + } + for side in ["proposed", "observed"] { + mark_party_field(difference, side); + } + } + } + entry +} + +#[cfg(test)] +#[path = "agent_presence_tests.rs"] +mod tests; diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs new file mode 100644 index 00000000..0ae12e95 --- /dev/null +++ b/src-tauri/src/agent_presence_tests.rs @@ -0,0 +1,1296 @@ +//! Every company, ledger, party and voucher number below is fabricated or is +//! the repository's existing synthetic capture. Nothing here is evidence about +//! a real book. +use super::*; +use bridge_tally_transport::TallyEndpointConfig; +use tally_protocol_simulator::{ + Fixture, ResponseFraming, ScenarioPlan, SequenceSimulator, WireEncoding, +}; + +const CAPTURED_GUID: &str = "61c6de69-1748-461c-ad3f-162cb949df9f"; +const GUID: &str = "00000000-0000-4000-8000-000000000001"; + +fn offline_server(directory: &std::path::Path) -> Server { + Server::new(Settings { + endpoint: TallyEndpointConfig { + host: "127.0.0.1".into(), + port: 9, + }, + data_dir: directory.to_path_buf(), + max_rows: 500, + max_bytes: 200_000, + redaction: Redaction::None, + import_enabled: false, + writes_enabled: false, + }) +} + +fn proposal(number: &str, party: &str, total: &str) -> Value { + json!({ + "date": "20260901", + "voucher_type": "Journal", + "voucher_number": number, + "party": party, + "entries": [ + {"ledger": party, "amount": format!("-{total}")}, + {"ledger": "WR2 Sales", "amount": total}, + ], + }) +} + +fn args(vouchers: Value, numbering: &str) -> Value { + json!({ + "company_guid": CAPTURED_GUID, + "from": "20260901", + "to": "20260930", + "numbering": [{"voucher_type": "Journal", "numbering_method": numbering}], + "vouchers": vouchers, + }) +} + +// --- admission, before any Tally read ---------------------------------- + +#[tokio::test] +async fn presence_arguments_are_bounded_before_any_tally_probe() { + let directory = tempfile::tempdir().expect("directory"); + let server = offline_server(directory.path()); + let base = proposal("JV-1", "Cash", "12.50"); + for (arguments, code) in [ + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}]}), + "vouchers_required", + ), + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "vouchers":[base.clone()]}), + "numbering_required", + ), + ( + json!({"company_guid":GUID,"from":"20260930","to":"20260901", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[base.clone()]}), + "invalid_date_range", + ), + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"sometimes"}], + "vouchers":[base.clone()]}), + "argument_invalid:numbering", + ), + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[]}), + "argument_invalid:vouchers", + ), + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[{"date":"20260901","voucher_type":"Journal","entries":[]}]}), + "argument_invalid:vouchers", + ), + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[{"date":"2-0-2-6-0-9-0-1","voucher_type":"Journal", + "entries":[{"ledger":"Cash","amount":"-1.00"},{"ledger":"WR2 Sales","amount":"1.00"}]}]}), + "argument_invalid:vouchers", + ), + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[{"date":"20260901","voucher_type":"Journal", + "entries":[{"ledger":"Cash","amount":"maybe"}]}]}), + "presence_amount_invalid", + ), + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[base.clone()],"ledger":"Cash"}), + "argument_unknown", + ), + ] { + let response = server + .call_tool_response("voucher_presence", arguments) + .await; + assert_eq!(response.value["isError"], true, "{code}"); + assert_eq!( + response.value["structuredContent"]["result"]["error"]["code"], code, + "{code}" + ); + // Nothing may cost a Tally read. + assert_eq!(response.value["structuredContent"]["evidence"]["bytes"], 0); + } +} + +#[tokio::test] +async fn an_over_large_proposal_set_is_refused_rather_than_trimmed() { + let directory = tempfile::tempdir().expect("directory"); + let server = offline_server(directory.path()); + let vouchers = vec![proposal("JV-1", "Cash", "12.50"); MAX_PRESENCE_VOUCHERS + 1]; + let response = server + .call_tool_response("voucher_presence", args(json!(vouchers), "manual")) + .await; + assert_eq!( + response.value["structuredContent"]["result"]["error"]["code"], + "argument_invalid:vouchers" + ); +} + +#[test] +fn the_published_schema_names_the_three_numbering_methods_and_its_bounds() { + let definitions = tool_definitions(true, false); + let tool = definitions + .as_array() + .and_then(|tools| tools.iter().find(|tool| tool["name"] == "voucher_presence")) + .expect("voucher_presence tool definition"); + let schema = &tool["inputSchema"]; + assert_eq!( + schema["properties"]["numbering"]["items"]["properties"]["numbering_method"]["enum"], + json!(["manual", "automatic", "unknown"]) + ); + assert_eq!( + schema["properties"]["vouchers"]["maxItems"], + json!(MAX_PRESENCE_VOUCHERS) + ); + assert_eq!( + schema["required"], + json!(["company_guid", "from", "to", "numbering", "vouchers"]) + ); + // The tool reads; it must not be annotated as a write. + assert!(tool.get("annotations").is_none()); + let description = tool["description"].as_str().expect("tool description"); + assert!(description.contains("manual")); + // The spelling matters: `safe_reason_code` returns the `presence_`-prefixed + // form, but a caller reads the serde one off an item's `reason` field, and a + // description advertising the wrong one is a string no caller can ever match. + assert!(description.contains("reason `window_not_proven_complete`")); + assert!(!description.contains("presence_window_not_proven_complete")); + assert!(!description.contains("REMOTEID")); +} + +/// `remote_id` is no longer an accepted input: the shipped read cannot fetch +/// `REMOTEID`, so supplying one could only ever withhold a verdict that a +/// unique manual number would otherwise settle. Refusing the input is more +/// honest than accepting it and degrading. +#[tokio::test] +async fn a_remote_id_is_not_an_accepted_input_at_this_surface() { + let definitions = tool_definitions(true, false); + let schema = definitions + .as_array() + .and_then(|tools| tools.iter().find(|tool| tool["name"] == "voucher_presence")) + .expect("voucher_presence schema")["inputSchema"] + .clone(); + assert!(schema["properties"]["vouchers"]["items"]["properties"] + .get("remote_id") + .is_none()); + let directory = tempfile::tempdir().expect("directory"); + let server = offline_server(directory.path()); + let response = server + .call_tool_response( + "voucher_presence", + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[{"date":"20260901","voucher_type":"Journal","remote_id":"tally-1", + "entries":[{"ledger":"Cash","amount":"-1.00"},{"ledger":"WR2 Sales","amount":"1.00"}]}]}), + ) + .await; + assert_eq!( + response.value["structuredContent"]["result"]["error"]["code"], + "argument_invalid:vouchers" + ); + assert_eq!(response.value["structuredContent"]["evidence"]["bytes"], 0); +} + +#[test] +fn the_result_is_pageable_so_an_over_large_report_is_not_discarded() { + // `page_shape` recognises `items` with an `offset`; without that this + // shape is untrimmable and a complete report is replaced wholesale by + // `agent_response_too_large` after every Tally read has been paid for. + let mut structured = json!({"result":{"offset":0,"total":3,"items": + (0..3).map(|id| json!({"position":id,"padding":"x".repeat(256)})).collect::>()}}); + let (bounded, trimmed, _) = + enforce_response_byte_cap(structured.clone(), 400).expect("trims rather than refusing"); + assert!(trimmed); + let kept = bounded["result"]["items"].as_array().expect("items"); + assert!(!kept.is_empty() && kept.len() < 3); + assert_eq!(bounded["result"]["next_offset"], kept.len()); + // And an untrimmed report keeps every row and offers no cursor. + structured["result"]["items"] = json!([{"position":0}]); + let (complete, trimmed, _) = enforce_response_byte_cap(structured, 10_000).expect("fits"); + assert!(!trimmed); + assert!(complete["result"].get("next_offset").is_none()); +} + +#[test] +fn a_caller_limited_presence_page_includes_its_resume_cursor() { + let entries = [ + ObservedEntry { + ledger: "Cash", + amount: "-1.00", + }, + ObservedEntry { + ledger: "Sales", + amount: "1.00", + }, + ]; + let proposal_inputs = [0, 1].map(|position| ProposedVoucherInput { + position, + date: "20260901", + voucher_type: "Journal", + voucher_number: Some(if position == 0 { "JV-0" } else { "JV-1" }), + remote_id: None, + party: None, + entries: &entries, + }); + let proposals = ProposedVoucher::from_inputs(proposal_inputs).expect("proposals"); + let window = BookWindow::from_observations( + "20260901", + "20260930", + WindowRead::Complete, + RemoteIdEvidence::NotRead, + 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"); + let numbering = + NumberingDeclaration::new([("Journal", NumberingMethod::Manual)]).expect("numbering"); + let request = + PresenceRequest::new(&window, &catalog, &numbering, &proposals).expect("presence request"); + let report = book_presence::assess(&request); + + let (result, truncated) = presence_result( + &report, + &catalogue, + WindowRead::Complete, + None, + 0, + 1, + 200_000, + ); + assert!(truncated); + assert_eq!(result["offset"], 0); + assert_eq!(result["total"], 2); + assert_eq!(result["items"].as_array().map(Vec::len), Some(1)); + assert_eq!(result["next_offset"], 1); +} + +#[test] +fn an_unknown_numbering_method_is_refused_at_the_published_schema() { + assert_eq!( + validate_tool_arguments( + "voucher_presence", + &json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[],"vouchers":[proposal("JV-1","Cash","12.50")]}), + ), + Err("argument_invalid:numbering".to_string()) + ); +} + +#[tokio::test] +async fn cross_input_refusals_also_cost_no_tally_read() { + // A date outside the window and an undeclared voucher type depend only on + // the arguments. Deferring them to the crate boundary would spend a + // company probe, a catalogue read and a full voucher window first. + let directory = tempfile::tempdir().expect("directory"); + let server = offline_server(directory.path()); + for (arguments, code) in [ + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[{"date":"20261015","voucher_type":"Journal", + "entries":[{"ledger":"Cash","amount":"-1.00"},{"ledger":"WR2 Sales","amount":"1.00"}]}]}), + "presence_window_does_not_cover", + ), + ( + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":[{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers":[{"date":"20260901","voucher_type":"Part and Labour Sale", + "entries":[{"ledger":"Cash","amount":"-1.00"},{"ledger":"WR2 Sales","amount":"1.00"}]}]}), + "presence_numbering_method_undeclared", + ), + ] { + let response = server + .call_tool_response("voucher_presence", arguments) + .await; + assert_eq!( + response.value["structuredContent"]["result"]["error"]["code"], code, + "{code}" + ); + assert_eq!(response.value["structuredContent"]["evidence"]["bytes"], 0); + } +} + +/// The bound is not restated anywhere, so the test must not restate it either: +/// it reads `maxLength` out of the published schema and proves the boundary +/// tracks it. If the schema moves, this moves with it; if the enforcement stops +/// following the schema, this fails. +#[tokio::test] +async fn nested_bounds_are_read_from_the_schema_rather_than_duplicated() { + let definitions = tool_definitions(true, false); + let schema = definitions + .as_array() + .and_then(|tools| tools.iter().find(|tool| tool["name"] == "voucher_presence")) + .map(|tool| tool["inputSchema"].clone()) + .expect("voucher_presence schema"); + let limit = schema["properties"]["vouchers"]["items"]["properties"]["voucher_number"] + ["maxLength"] + .as_u64() + .expect("a published maxLength") as usize; + let entries = + json!([{"ledger":"Cash","amount":"-1.00"},{"ledger":"WR2 Sales","amount":"1.00"}]); + let numbering = json!([{"voucher_type":"Journal","numbering_method":"manual"}]); + let directory = tempfile::tempdir().expect("directory"); + let server = offline_server(directory.path()); + for (length, refused) in [(limit, false), (limit + 1, true)] { + let response = server + .call_tool_response( + "voucher_presence", + json!({"company_guid":GUID,"from":"20260901","to":"20260930","numbering":numbering, + "vouchers":[{"date":"20260901","voucher_type":"Journal", + "voucher_number":"x".repeat(length),"entries":entries}]}), + ) + .await; + let code = &response.value["structuredContent"]["result"]["error"]["code"]; + assert_eq!( + code == "argument_invalid:vouchers", + refused, + "length {length} against a published limit of {limit}" + ); + assert_eq!(response.value["structuredContent"]["evidence"]["bytes"], 0); + } +} + +#[tokio::test] +async fn nested_arguments_are_bounded_to_the_published_schema() { + let directory = tempfile::tempdir().expect("directory"); + let server = offline_server(directory.path()); + let long = "x".repeat(agent_import::MAX_MASTER_NAME_CHARS + 1); + let entries = + json!([{"ledger":"Cash","amount":"-1.00"},{"ledger":"WR2 Sales","amount":"1.00"}]); + for (vouchers, numbering, code) in [ + // A voucher number past the advertised 1024 characters. + ( + json!([{"date":"20260901","voucher_type":"Journal","voucher_number":long,"entries":entries}]), + json!([{"voucher_type":"Journal","numbering_method":"manual"}]), + "argument_invalid:vouchers", + ), + // A ledger name past the advertised limit. + ( + json!([{"date":"20260901","voucher_type":"Journal", + "entries":[{"ledger":long,"amount":"-1.00"},{"ledger":"WR2 Sales","amount":"1.00"}]}]), + json!([{"voucher_type":"Journal","numbering_method":"manual"}]), + "argument_invalid:vouchers", + ), + // An amount past the advertised 64 characters. + ( + json!([{"date":"20260901","voucher_type":"Journal", + "entries":[{"ledger":"Cash","amount":"1".repeat(65)},{"ledger":"WR2 Sales","amount":"1.00"}]}]), + json!([{"voucher_type":"Journal","numbering_method":"manual"}]), + "argument_invalid:vouchers", + ), + // A nested property the schema does not declare. + ( + json!([{"date":"20260901","voucher_type":"Journal","narration":"hello","entries":entries}]), + json!([{"voucher_type":"Journal","numbering_method":"manual"}]), + "argument_invalid:vouchers", + ), + // A blank nested string. + ( + json!([{"date":"20260901","voucher_type":"Journal","party":" ","entries":entries}]), + json!([{"voucher_type":"Journal","numbering_method":"manual"}]), + "argument_invalid:vouchers", + ), + // The same discipline on the numbering declaration. + ( + json!([{"date":"20260901","voucher_type":"Journal","entries":entries}]), + json!([{"voucher_type":"Journal","numbering_method":"manual","note":"x"}]), + "argument_invalid:numbering", + ), + ] { + let response = server + .call_tool_response( + "voucher_presence", + json!({"company_guid":GUID,"from":"20260901","to":"20260930", + "numbering":numbering,"vouchers":vouchers}), + ) + .await; + assert_eq!( + response.value["structuredContent"]["result"]["error"]["code"], code, + "{code}" + ); + assert_eq!(response.value["structuredContent"]["evidence"]["bytes"], 0); + } +} + +// --- typed parses ------------------------------------------------------- + +#[test] +fn case_distinct_voucher_types_keep_independent_numbering_declarations() { + // Voucher types are identity, not master names: folding their case could + // let two distinct Tally types claim the same manual-number namespace. + // Exact duplicates with differing methods still fail in the core type. + parse_numbering(&json!({"numbering":[ + {"voucher_type":"Journal","numbering_method":"manual"}, + {"voucher_type":"journal","numbering_method":"automatic"}, + ]})) + .expect("case-distinct voucher types are independent"); +} + +#[test] +fn a_window_row_becomes_a_book_voucher_without_inventing_a_remote_id() { + let row = json!({ + "guid": format!("{CAPTURED_GUID}-00000001"), + "date": "20260901", + "voucher_number": "JV-1", + "voucher_type": "Journal", + "party": "Bridge Nested Debtor WR4", + "cancelled": false, + "optional": false, + "amounts": [ + {"ledger": "Bridge Nested Debtor WR4", "amount": "-12.50"}, + {"ledger": "WR2 Sales", "amount": "12.50"}, + ], + }); + let window = + book_window("20260901", "20260901", WindowRead::Complete, &[row]).expect("book window"); + let voucher = &window.vouchers()[0]; + assert_eq!(voucher.key(), format!("{CAPTURED_GUID}-00000001")); + assert_eq!(voucher.magnitude().as_str(), "12.5"); + assert!(voucher.balanced()); + assert_eq!(voucher.party(), Some("Bridge Nested Debtor WR4")); +} + +/// A voucher that names nobody reaches this adapter as `"party": ""`, because +/// the window JSON spells an absent string that way rather than omitting the +/// key. Read literally that says the voucher *has* a party whose name is blank, +/// and two guards then fired on a book that merely had such a voucher: the +/// catalogue check reported all 864 masters incomplete, and the presence engine +/// refused the window with `presence_text_blank`. +/// +/// Tally does send an empty party element -- it is a real shape, not our +/// invention -- which is exactly why this has to mean "no party" rather than +/// being refused. +#[test] +fn a_voucher_naming_nobody_has_no_party_rather_than_a_blank_one() { + let row = json!({ + "guid": format!("{CAPTURED_GUID}-00000002"), + "date": "20260901", + "voucher_number": "JV-2", + "voucher_type": "Journal", + "party": "", + "cancelled": false, + "optional": false, + "amounts": [ + {"ledger": "WR2 Purchases", "amount": "-12.50"}, + {"ledger": "WR2 Sales", "amount": "12.50"}, + ], + }); + let window = book_window("20260901", "20260901", WindowRead::Complete, &[row]) + .expect("a voucher with no party must not fail the window"); + let voucher = &window.vouchers()[0]; + assert_eq!( + voucher.party(), + None, + "an empty party is an absent party, not a party named the empty string" + ); + assert_eq!(voucher.magnitude().as_str(), "12.5"); +} + +#[test] +fn party_names_are_marked_for_egress_and_accounting_selectors_are_not() { + let entry = json!({ + "position": 0, + "voucher_number": "JV-1", + "party": {"party_state": "bound", "catalog_name": "Bridge Nested Debtor WR4"}, + "presence": "present", + "book_key": "book-1", + "differences": [ + {"field": "party", "proposed": "Debtor As Written", "observed": "Bridge Nested Debtor WR4"}, + {"field": "amount", "proposed": "12.5", "observed": "11.5"}, + {"field": "voucher_type", "proposed": "Sales", "observed": "Receipt"}, + ], + }); + let marked = mark_presence_party_names(entry); + assert_eq!( + marked["party"]["catalog_name"][super::super::PARTY_NAME_MARKER], + "Bridge Nested Debtor WR4" + ); + assert_eq!( + marked["differences"][0]["proposed"][super::super::PARTY_NAME_MARKER], + "Debtor As Written" + ); + // An amount is not a party name and must not be wrapped. + assert_eq!(marked["differences"][1]["proposed"], "12.5"); + assert_eq!(marked["differences"][2]["field"], "voucher_type"); + assert_eq!(marked["differences"][2]["observed"], "Receipt"); + assert_eq!(marked["voucher_number"], "JV-1"); + let masked = redact_value(marked, Redaction::MaskParties); + let text = masked.to_string(); + assert!(!text.contains("Bridge Nested Debtor WR4"), "{text}"); + assert!(text.contains("12.5")); +} + +// --- one live-shaped cycle --------------------------------------------- + +fn company_xml() -> String { + format!("
1
{CAPTURED_GUID}120260401
") +} + +fn catalogue_xml() -> String { + let bytes = include_bytes!( + "../crates/bridge-tally-protocol/tests/fixtures/agent/native-ledger-catalogue.utf16le.xml" + ); + let words = bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(); + String::from_utf16(&words).expect("captured native catalogue") +} + +/// Two vouchers already in the book, one of them posted short of its source. +fn window_xml() -> String { + format!( + concat!( + "
1
", + "20260901JV-1", + "Journal{guid}-00000001", + "112", + "Bridge Nested Debtor WR4", + "NoNo", + "Bridge Nested Debtor WR4", + "Yes-12.50", + "WR2 Sales", + "No12.50", + "20260902JV-2", + "Journal{guid}-00000002", + "213", + "Café Naïve Traders", + "NoNo", + "Café Naïve Traders", + "Yes-7.00", + "WR2 Sales", + "No7.00", + "
" + ), + guid = CAPTURED_GUID + ) +} + +/// The shapes the runtime actually issues: an identity pair, then one block +/// per paired native read. Built rather than hand-indexed, because this tool +/// performs three reads and an off-by-one in a literal list is a debugging +/// session, not a test failure. +enum Step { + Company, + Status, + Payload(String), +} + +fn paired_read(payload: &str) -> Vec { + vec![ + Step::Company, + Step::Payload(payload.to_string()), + Step::Status, + Step::Payload(payload.to_string()), + Step::Status, + Step::Company, + ] +} + +/// Turns the step list into simulator plans. Separated from `presence_plans` +/// so a test that varies one payload does not restate the framing of all six. +fn plans(steps: Vec) -> Vec { + steps + .into_iter() + .map(|step| match step { + Step::Status => ScenarioPlan::new(Fixture::ProductStatus( + tally_protocol_simulator::ProductStatus::TallyPrime, + )) + .with_framing(ResponseFraming::ContentLength), + Step::Company => ScenarioPlan::new(Fixture::SyntheticXml(company_xml())) + .with_encoding(WireEncoding::Utf16Le) + .with_framing(ResponseFraming::ContentLength), + Step::Payload(body) => ScenarioPlan::new(Fixture::SyntheticXml(body)) + .with_encoding(WireEncoding::Utf16Le) + .with_framing(ResponseFraming::ContentLength), + }) + .collect() +} + +fn presence_plans() -> Vec { + let catalogue = catalogue_xml(); + let mut steps = vec![Step::Company, Step::Status, Step::Company, Step::Status]; + // Catalogue, then the voucher window, then the paired-snapshot catalogue + // reread the nonempty (necessarily `Partial`) window path takes before it + // can still produce `present`/`possibly_present` verdicts. + steps.extend(paired_read(&catalogue)); + steps.extend(paired_read(&window_xml())); + steps.extend(paired_read(&catalogue)); + plans(steps) +} + +#[tokio::test] +async fn a_nonempty_window_without_a_control_total_still_answers_but_never_issues_absent() { + let simulator = SequenceSimulator::spawn(presence_plans()).expect("simulator"); + let directory = tempfile::tempdir().expect("directory"); + let server = Server::new(Settings { + endpoint: TallyEndpointConfig { + host: simulator.address().ip().to_string(), + port: simulator.address().port(), + }, + data_dir: directory.path().to_path_buf(), + max_rows: 500, + max_bytes: 200_000, + redaction: Redaction::None, + import_enabled: false, + writes_enabled: false, + }); + let response = server + .call_tool( + "voucher_presence", + json!({ + "company_guid": CAPTURED_GUID, + "from": "20260901", + "to": "20260930", + "numbering": [{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers": [ + // Already in the book, and the book agrees. + proposal("JV-1", "Bridge Nested Debtor WR4", "12.50"), + // Already in the book, posted short by 0.50. + proposal("JV-2", "Café Naïve Traders", "7.50"), + // Not in the book, and nothing resembles it. + proposal("JV-9", "नमस्ते ट्रेडर्स", "99.00"), + ], + }), + ) + .await; + // A nonempty response has no source-side cardinality control, so the + // window is `Partial` and can never license `Absent` -- but `Present` and + // `PossiblyPresent` need no completeness proof, so the tool still answers + // rather than refusing the whole request the way it used to. + assert_eq!(response["isError"], false, "{response}"); + let result = &response["structuredContent"]["result"]; + assert_eq!(result["window"]["read"], "partial"); + assert_eq!( + result["totals"], + json!({"requested": 3, "present": 2, "possibly_present": 1, "absent": 0}) + ); + let items = result["items"].as_array().expect("items"); + assert_eq!(items[0]["presence"], "present", "{items:?}"); + assert_eq!(items[1]["presence"], "present", "{items:?}"); + assert_eq!(items[2]["presence"], "possibly_present", "{items:?}"); + assert_eq!( + items[2]["reason"], "window_not_proven_complete", + "nothing resembled JV-9, but the window that found nothing was never \ + proven complete, so it must not be reported absent" + ); + assert_eq!( + response["structuredContent"]["evidence"]["state"], + "partial" + ); + let observed = simulator.finish().expect("requests"); + assert_eq!(observed.len(), 22); +} + +/// The admission contract this tool enforces lives in `agent_catalog.rs`, and +/// that file is **not** in the compatibility surface — so an edit confined to +/// it could loosen what a caller may send while the sealed digest and the +/// evidence beneath it stayed unchanged. +/// +/// The numeric bounds are safe already: the schema references constants that +/// live in pinned files. What an unpinned edit could change is the *structure* +/// — dropping `additionalProperties`, widening the numbering enum, removing a +/// required field. So the structure is asserted here, in a pinned file, which +/// makes a silent loosening fail a test rather than pass a seal. +/// +/// Pinning `agent_catalog.rs` instead would also work and is strictly +/// stronger, but it is a shared decision rather than this lane's: that file is +/// edited by every tool change, so pinning it makes every such change reseal, +/// and it would move this PR's `MAX_SURFACE_FILES` arithmetic that the merge +/// order already depends on. +#[test] +fn every_admission_leaf_is_pinned_by_this_digest() { + // The assertions below this one say what the contract *means*, and they + // are worth reading. They cannot be complete: the parser drives itself + // from the published schema, so every leaf in it is admission-relevant, + // and a review found the previous version silently omitting + // `entries.items.properties.amount.maxLength` among others. Enumerating + // leaves is a list that goes stale; a digest over the whole schema cannot. + // + // This file is pinned into the compatibility surface, so changing the + // schema now forces this constant to change, which moves the surface + // digest, which is exactly the visibility the seal is for. If this fails + // and the schema change was deliberate, update the constant *and* reseal + // — that pairing is the point, not an inconvenience. + const PINNED: &str = "6b2f7f67269beaf40631057eeb3ccd563360239393129dc082c0755b5ff3a31c"; + let definitions = tool_definitions(true, false); + let schema = definitions + .as_array() + .and_then(|tools| tools.iter().find(|tool| tool["name"] == "voucher_presence")) + .expect("voucher_presence tool")["inputSchema"] + .clone(); + // `serde_json::Value` orders object keys, so this is canonical already, + // and `sha256_json` is the digest this module already uses for evidence. + let digest = sha256_json(&schema); + assert_eq!( + digest, PINNED, + "the published admission contract changed; update this digest in the same commit that \ + reseals the compatibility surface" + ); +} + +#[test] +fn the_admission_contract_cannot_be_loosened_without_failing_something() { + let definitions = tool_definitions(true, false); + let schema = definitions + .as_array() + .and_then(|tools| tools.iter().find(|tool| tool["name"] == "voucher_presence")) + .expect("voucher_presence tool")["inputSchema"] + .clone(); + let voucher = &schema["properties"]["vouchers"]["items"]; + let numbering = &schema["properties"]["numbering"]["items"]; + + // Nothing undeclared may be sent, at any level. + for object in [ + &schema, + voucher, + numbering, + &voucher["properties"]["entries"]["items"], + ] { + assert_eq!( + object["additionalProperties"], + json!(false), + "an undeclared property would be accepted here" + ); + } + // The three numbering methods are the vocabulary; a fourth would mean the + // crate's `Unknown` fallback silently absorbed it. + assert_eq!( + numbering["properties"]["numbering_method"]["enum"], + json!(["manual", "automatic", "unknown"]) + ); + // A proposal without entries has no magnitude, and one without a date or + // type cannot be placed in a window. + assert_eq!( + voucher["required"], + json!(["date", "voucher_type", "entries"]) + ); + assert_eq!( + numbering["required"], + json!(["voucher_type", "numbering_method"]) + ); + assert_eq!( + voucher["properties"]["entries"]["items"]["required"], + json!(["ledger", "amount"]) + ); + // REMOTEID matching is unreachable from the shipped read, so the input + // stays absent rather than accepted-and-degraded. + assert!(voucher["properties"].get("remote_id").is_none()); + // Every bound the parser relies on is still stated, since the parser reads + // them from here rather than restating them. + for (path, expected) in [ + ( + &voucher["properties"]["voucher_type"]["maxLength"], + agent_import::MAX_MASTER_NAME_CHARS, + ), + ( + &voucher["properties"]["voucher_number"]["maxLength"], + agent_import::MAX_MASTER_NAME_CHARS, + ), + ( + &voucher["properties"]["party"]["maxLength"], + agent_import::MAX_MASTER_NAME_CHARS, + ), + ] { + assert_eq!(path.as_u64(), Some(expected as u64)); + } + assert_eq!( + voucher["properties"]["entries"]["maxItems"].as_u64(), + Some(MAX_PRESENCE_ENTRIES as u64) + ); +} + +/// A ledger the book posts to that the catalogue never listed proves the +/// catalogue short. Both catalogue reads agreeing only proves they agree. +/// +/// Left unchecked this is the quiet failure: a proposal naming that ledger +/// binds `Unmatched`, so every party rule declines to run, and an `Absent` +/// gets authorised off a comparison that was never possible — which is the +/// duplicate this whole contract exists to prevent. +#[tokio::test] +async fn a_ledger_missing_from_the_catalogue_fails_closed() { + let unlisted = window_xml().replace("WR2 Sales", "WR2 Sales Not In Catalogue"); + let catalogue = catalogue_xml(); + let mut steps = vec![Step::Company, Step::Status, Step::Company, Step::Status]; + steps.extend(paired_read(&catalogue)); + steps.extend(paired_read(&unlisted)); + let simulator = SequenceSimulator::spawn(plans(steps)).expect("simulator"); + let directory = tempfile::tempdir().expect("directory"); + let server = Server::new(Settings { + endpoint: TallyEndpointConfig { + host: simulator.address().ip().to_string(), + port: simulator.address().port(), + }, + data_dir: directory.path().to_path_buf(), + max_rows: 500, + max_bytes: 200_000, + redaction: Redaction::None, + import_enabled: false, + writes_enabled: false, + }); + let response = server + .call_tool_response( + "voucher_presence", + json!({ + "company_guid": CAPTURED_GUID, + "from": "20260901", + "to": "20260930", + "numbering": [{"voucher_type":"Journal","numbering_method":"manual"}], + // Dated and numbered away from both book rows, so nothing but + // the party could have surfaced them. Without the guard this + // returns `absent` and a caller imports a second copy. + "vouchers": [proposal("JV-77", "WR2 Sales Not In Catalogue", "12.50")], + }), + ) + .await; + assert_eq!( + response.value["structuredContent"]["result"]["error"]["code"], + "ledger_catalogue_incomplete" + ); +} + +/// A party's entity shape is decided entirely by the caller's text, so the +/// tool must refuse it before spending a read — the promise every other +/// argument refusal on this tool already keeps. +/// +/// The endpoint here is a *live simulator*, deliberately. Against an offline +/// server this assertion passes whether or not the guard exists, because a +/// failed connection also spends no bytes: the test could not tell "refused +/// before reading" from "the read did not work". With reads available, zero +/// bytes means the refusal really did come first. +#[tokio::test] +async fn a_party_with_too_many_identifiers_costs_no_read() { + let party = (1..=33) + .map(|index| format!("{:08}", 10_000_000 + index)) + .collect::>() + .join(" "); + let simulator = SequenceSimulator::spawn(presence_plans()).expect("simulator"); + let directory = tempfile::tempdir().expect("directory"); + let server = Server::new(Settings { + endpoint: TallyEndpointConfig { + host: simulator.address().ip().to_string(), + port: simulator.address().port(), + }, + data_dir: directory.path().to_path_buf(), + max_rows: 500, + max_bytes: 200_000, + redaction: Redaction::None, + import_enabled: false, + writes_enabled: false, + }); + let response = server + .call_tool_response( + "voucher_presence", + json!({ + "company_guid": CAPTURED_GUID, + "from": "20260901", + "to": "20260930", + "numbering": [{"voucher_type":"Journal","numbering_method":"manual"}], + "vouchers": [proposal("JV-1", &party, "12.50")], + }), + ) + .await; + assert_eq!( + response.value["structuredContent"]["result"]["error"]["code"], + "master_identifiers_too_many" + ); + assert_eq!( + response.value["structuredContent"]["evidence"]["bytes"], 0, + "an input that was always going to be refused must cost no read" + ); +} +/// The observations can outgrow the byte cap on their own, and `fit_response` +/// cannot reach them - it trims `items`, and `book` is a sibling. Worse, the +/// final framing serializes the payload twice, so the real cost is doubled. +/// +/// The premise is asserted first: a `book` at the crate's documented maxima +/// really does exceed the budget. Without that, the degradation below would be +/// a control whose branch never fires. +#[test] +fn a_maximal_book_degrades_to_its_counts_rather_than_costing_the_report() { + // Both bounds count *characters*, so the widest value they admit is a + // four-byte one. A real Tally GUID is 36 ASCII bytes and nowhere near + // this; the guard exists because the contract permits this, not because + // the common case needs it. + let key = "\u{1f600}".repeat(book_presence::MAX_BOOK_KEY_CHARS); + let label = "\u{1f600}".repeat(book_presence::MAX_OBSERVATION_LABEL_CHARS); + let groups = (0..book_presence::MAX_DUPLICATE_NUMBER_GROUPS) + .map(|_| { + json!({ + "voucher_type": label, "voucher_number": label, + "book_keys": (0..book_presence::MAX_KEYS_PER_DUPLICATE_GROUP) + .map(|_| key.clone()).collect::>(), + "book_voucher_count": 10, + }) + }) + .collect::>(); + let book = json!({ + "duplicate_numbers": groups, + "duplicate_number_group_count": book_presence::MAX_DUPLICATE_NUMBER_GROUPS, + "duplicate_numbers_truncated": false, + "unbalanced_vouchers": (0..book_presence::MAX_UNBALANCED_LISTED) + .map(|_| key.clone()).collect::>(), + "unbalanced_voucher_count": book_presence::MAX_UNBALANCED_LISTED, + "unmatched_book_vouchers": 0, "window_voucher_count": 20_000, + "remote_id_observed": false, + }); + let budget = 200_000 / OBSERVATION_BUDGET_DIVISOR; + let full = book.to_string().len(); + assert!( + full > budget, + "the premise fails: a maximal book is {full} bytes against a budget of {budget}" + ); + assert!( + full * 2 > 200_000, + "the doubled envelope should clear the default cap on observations alone" + ); + + let bounded = bounded_observations(book, budget); + assert!(bounded.to_string().len() <= budget); + // Every count survives, and the listing keeps as many rows as fit rather + // than emptying: dropping the lot would satisfy a laxer assertion than + // this one, so the retained count is bounded on both sides. + let listed = bounded["duplicate_numbers"] + .as_array() + .expect("listed") + .len(); + assert!( + listed < book_presence::MAX_DUPLICATE_NUMBER_GROUPS, + "nothing was trimmed" + ); + assert!( + listed > 0, + "the whole listing was dropped rather than trimmed" + ); + assert_eq!(bounded["listings_withheld_for_size"], json!(true)); + assert_eq!(bounded["duplicate_numbers_truncated"], json!(true)); + assert_eq!( + bounded["duplicate_number_group_count"], + json!(book_presence::MAX_DUPLICATE_NUMBER_GROUPS) + ); + assert_eq!(bounded["window_voucher_count"], json!(20_000)); + + // A book that fits comes back untouched, with no marker added. + let small = json!({"duplicate_numbers": [], "window_voucher_count": 3}); + assert_eq!(bounded_observations(small.clone(), budget), small); + + // And a book that is over by a little keeps most of its listing rather + // than losing all of it -- the row-by-row part, which a wholesale drop + // would pass the assertions above without ever doing. + let rows = (0..40).map(|_| json!(key)).collect::>(); + let large = json!({"duplicate_numbers": [], "unbalanced_vouchers": rows, + "unbalanced_voucher_count": 40, "window_voucher_count": 40}); + let kept = bounded_observations(large, 12_000); + let listed = kept["unbalanced_vouchers"] + .as_array() + .expect("listed") + .len(); + assert!( + (1..40).contains(&listed), + "expected a partial listing, kept {listed} of 40" + ); + assert_eq!(kept["unbalanced_voucher_count"], json!(40)); + assert_eq!(kept["listings_withheld_for_size"], json!(true)); +} + +// --------------------------------------------------------------------------- +// Live replay — manual, owner-authorized, and read-only. +// +// The synthetic cycle above verifies the rules against data this repository +// invented. This replays the shape of the engagement that motivated the +// capability against a real book: twenty proposed invoices, most of which the +// book already holds, one of them differing in amount. +// +// Two properties make it safe to keep in a public repository: +// +// * It **never writes.** The proposals are built from the book's own rows, +// so the "already present" ones are present by construction and no voucher +// is posted to produce them. That inverts one detail of the original +// engagement and the assertion says so. +// * It **emits no book content** — counts, bases and reason codes only. A +// failure prints what went wrong, never a party name, number or amount. +// The observation object is filtered to its scalar fields to keep that +// true: its listed groups carry real voucher numbers, types and GUIDs, +// and `--nocapture` output reaches terminals and CI logs. +// --------------------------------------------------------------------------- + +/// How many faithful copies to propose, how many to perturb, how many to invent. +const REPLAY_PRESENT: usize = 15; +const REPLAY_DIFFERING: usize = 1; +const REPLAY_ABSENT: usize = 4; +/// The engagement's invoice was posted 36.13 short of its source document. +const SHORT_BY_PAISE: i64 = 3_613; +/// A party the book has never seen, so nothing it proposes can resemble a row +/// by party. Fabricated, and it must stay that way. +const REPLAY_UNKNOWN_PARTY: &str = "Bridge Replay Unknown Party"; + +fn live_env(key: &str) -> String { + std::env::var(key).unwrap_or_else(|_| panic!("{key} must be set for the live replay")) +} + +/// Confirms that a live nonempty window remains fail-closed until a source +/// cardinality control exists. +/// +/// ```text +/// BRIDGE_TALLY_LIVE_PORT=9001 \ +/// BRIDGE_TALLY_LIVE_COMPANY_GUID= \ +/// BRIDGE_PRESENCE_LIVE_FROM=YYYYMMDD BRIDGE_PRESENCE_LIVE_TO=YYYYMMDD \ +/// BRIDGE_PRESENCE_LIVE_MANUAL_TYPES= \ +/// cargo test -p bridge --lib replay_the_twenty_invoice_engagement -- --ignored --nocapture +/// ``` +#[tokio::test] +#[ignore = "manual owner-authorized live read; needs the lab reachable on the given port"] +async fn replay_the_twenty_invoice_engagement() { + let port = live_env("BRIDGE_TALLY_LIVE_PORT") + .parse::() + .expect("numeric port"); + let guid = live_env("BRIDGE_TALLY_LIVE_COMPANY_GUID"); + let from = live_env("BRIDGE_PRESENCE_LIVE_FROM"); + let to = live_env("BRIDGE_PRESENCE_LIVE_TO"); + let directory = tempfile::tempdir().expect("directory"); + let server = Server::new(Settings { + endpoint: TallyEndpointConfig { + host: "127.0.0.1".into(), + port, + }, + data_dir: directory.path().to_path_buf(), + max_rows: 500, + max_bytes: 4_000_000, + redaction: Redaction::None, + import_enabled: false, + // Read-only, and stated in the settings rather than only in a comment. + writes_enabled: false, + }); + + // Scope the read to a manually numbered type. Presence by voucher number is + // only meaningful under `Manual` -- under `Automatic` Tally discards the + // supplied number -- and a real book is overwhelmingly automatic: every + // month of the reference book holds ~150 automatic vouchers against ~50 + // manual ones. Reading the whole window and taking the first rows therefore + // picks up automatic types and trips the manual-numbering precondition + // below, which is why this replay had never been runnable against a real + // book. One declared type per run keeps the selection honest. + let selected_type = std::env::var("BRIDGE_PRESENCE_LIVE_VOUCHER_TYPE").ok(); + let mut request = json!({"company_guid": guid, "from": from, "to": to}); + if let Some(kind) = selected_type.as_deref() { + request["voucher_type"] = json!(kind); + } + let read = server.call_tool("vouchers", request).await; + // Carry the refusal into the failure text. Asserting on `isError` alone + // reports that the read failed and withholds the one thing that says why, + // which cost a full diagnosis round the first time this fired. + assert_eq!( + read["isError"], + false, + "the window read failed: {}", + serde_json::to_string(&read).unwrap_or_default() + ); + let rows = read["structuredContent"]["result"]["items"] + .as_array() + .expect("items") + .clone(); + let posted = rows + .iter() + .filter(|row| { + row["cancelled"] != json!(true) + && row["optional"] != json!(true) + && row["voucher_number"].is_string() + && row["party"].is_string() + && row["amounts"].as_array().is_some_and(|rows| rows.len() > 1) + }) + .collect::>(); + let needed = REPLAY_PRESENT + REPLAY_DIFFERING; + assert!( + posted.len() >= needed, + "the window holds {} usable vouchers and the replay needs {needed}; widen the dates", + posted.len() + ); + + // Paise, so the shortfall is exact. The read carries `bill_allocations` + // and `is_deemed_positive` that the proposal schema does not declare, so + // each entry is projected down to what a source document actually offers. + let paise = |amount: &str| -> i64 { + let (sign, digits) = match amount.strip_prefix('-') { + Some(rest) => (-1, rest), + None => (1, amount), + }; + let (whole, fraction) = digits.split_once('.').unwrap_or((digits, "0")); + let fraction = format!("{fraction:0<2}"); + sign * (whole.parse::().expect("whole") * 100 + + fraction[..2].parse::().expect("fraction")) + }; + let rupees = |value: i64| { + format!( + "{}{}.{:02}", + if value < 0 { "-" } else { "" }, + value.abs() / 100, + value.abs() % 100 + ) + }; + + // Magnitude is the sum of the non-negative entries, so a shortfall must + // come off that side to be a difference at all — and it must not push the + // entry through zero, or the entry leaves the sum entirely and the + // magnitude moves by its whole value rather than by the shortfall. The + // test would still see *a* difference and still pass, measuring something + // other than what it says it measures. So it is taken off the largest + // positive entry, and only where that entry can absorb it. + let widest_positive = |row: &Value, minimum: i64| -> Option { + row["amounts"] + .as_array() + .expect("amounts") + .iter() + .enumerate() + .map(|(at, entry)| (at, paise(entry["amount"].as_str().expect("amount")))) + .filter(|(_, value)| *value > minimum) + .max_by_key(|(_, value)| *value) + .map(|(at, _)| at) + }; + + let proposal_from = |row: &Value, short_by: i64| { + let target = + (short_by > 0).then(|| widest_positive(row, short_by).expect("an entry to shorten")); + let entries = row["amounts"] + .as_array() + .expect("amounts") + .iter() + .enumerate() + .map(|(at, entry)| { + let value = paise(entry["amount"].as_str().expect("amount")); + let adjusted = if target == Some(at) { + value - short_by + } else { + value + }; + json!({"ledger": entry["ledger"], "amount": rupees(adjusted)}) + }) + .collect::>(); + json!({ + "date": row["date"], + "voucher_type": row["voucher_type"], + "voucher_number": row["voucher_number"], + "party": row["party"], + "entries": entries, + }) + }; + + let mut proposals = posted + .iter() + .take(REPLAY_PRESENT) + .map(|row| proposal_from(row, 0)) + .collect::>(); + // The engagement's short-posted invoice was short in the *book*. This + // harness may not write, so the shortfall is introduced on the proposal + // side instead. The difference the report must find is the same one; only + // which side is missing the GST head is reversed. + // The row to shorten has to be able to absorb the shortfall. Picking + // blindly is how the perturbation silently becomes a different one. + let shortened = posted + .iter() + .skip(REPLAY_PRESENT) + .find(|row| widest_positive(row, SHORT_BY_PAISE).is_some()) + .expect("a voucher whose invoice line exceeds the shortfall"); + proposals.push(proposal_from(shortened, SHORT_BY_PAISE)); + // A new customer's invoice, which the engagement also had. It must differ + // from every book row in *party and amount*, not just in number: in a + // one-day window every row shares the date, so a known party alone would + // resemble something on date-and-party and withhold `absent` -- correctly, + // and that is a property of the window rather than of the proposal. + for index in 0..REPLAY_ABSENT { + let short_by = (index as i64 + 1) * 7_777; + let seed = posted + .iter() + .find(|row| widest_positive(row, short_by).is_some()) + .expect("a voucher whose invoice line exceeds each invented shortfall"); + let mut invented = proposal_from(seed, short_by); + invented["voucher_number"] = json!(format!("BRIDGE-REPLAY-ABSENT-{index:02}")); + invented["party"] = json!(REPLAY_UNKNOWN_PARTY); + invented["entries"][0]["ledger"] = json!(REPLAY_UNKNOWN_PARTY); + proposals.push(invented); + } + + // The numbering method is an *assertion about the book*, and the harness + // is not entitled to make it. Declaring an automatically numbered type + // `manual` would let Tally's own numbers produce `present` and the replay + // would pass on verdicts the contract says are not identity — evidence + // manufactured by the test rather than found in the book. So the operator + // names the manually numbered types and the replay refuses any other. + let declared = live_env("BRIDGE_PRESENCE_LIVE_MANUAL_TYPES"); + let declared = declared + .split(',') + .map(str::trim) + .filter(|kind| !kind.is_empty()) + .collect::>(); + // `shortened` is whichever row past the faithful slice could absorb the + // perturbation, not necessarily `posted[REPLAY_PRESENT]` -- so the types + // in the declaration have to be read off the rows that actually became + // proposals (the faithful fifteen plus `shortened`) rather than off the + // first `needed` rows of `posted`, or a shortfall landing on a later type + // leaves that type's proposal without a numbering declaration at all. + let mut types = posted + .iter() + .take(REPLAY_PRESENT) + .chain(std::iter::once(shortened)) + .filter_map(|row| row["voucher_type"].as_str()) + .collect::>(); + types.sort_unstable(); + types.dedup(); + for kind in &types { + assert!( + declared.contains(kind), + "a voucher type in this window was not declared manually numbered; \ + set BRIDGE_PRESENCE_LIVE_MANUAL_TYPES or narrow the window" + ); + } + let numbering = types + .iter() + .map(|kind| json!({"voucher_type": kind, "numbering_method": "manual"})) + .collect::>(); + + let response = server + .call_tool( + "voucher_presence", + json!({"company_guid": guid, "from": from, "to": to, + "numbering": numbering, "vouchers": proposals}), + ) + .await; + // A nonempty window still cannot be proven complete -- no source-side + // cardinality control exists -- but that now withholds exactly one verdict + // instead of refusing the request. The window answers; nothing in it may + // come back `absent`, because absence is the only claim that needs to have + // seen the whole range. + assert_eq!( + response["isError"], false, + "a nonempty window must answer rather than refuse: {response}" + ); + let result = &response["structuredContent"]["result"]; + assert_eq!(result["window"]["read"], "partial"); + assert_eq!( + result["totals"]["absent"], 0, + "no proposal may be reported absent from a window that was never \ + proven complete: {result}" + ); + for item in result["items"].as_array().expect("items") { + assert_ne!( + item["presence"], "absent", + "absent requires a proven-complete window: {item}" + ); + } + assert_eq!( + response["structuredContent"]["evidence"]["state"], + "partial" + ); +} diff --git a/src-tauri/src/agent_tests.rs b/src-tauri/src/agent_tests.rs index 0b13a8fa..c86725fb 100644 --- a/src-tauri/src/agent_tests.rs +++ b/src-tauri/src/agent_tests.rs @@ -335,10 +335,18 @@ fn mask_parties_walks_every_tool_sample_response_without_leaking_party_names() { "trial_balance", json!({"ledgers":[{"ledger":party_name("Entry Ledger")}]}), ), + ( + "voucher_presence", + json!({"vouchers":[super::presence::mark_presence_party_names(json!({ + "party":{"party_state":"bound","catalog_name":"Customer One"}, + "presence":"present", + "differences":[{"field":"party","proposed":"Customer One","observed":"Supplier Two"}], + }))]}), + ), ("read_evidence", json!({"records":[]})), ("egress_log", json!({"records":[]})), ]); - assert_eq!(samples.len(), 14); + assert_eq!(samples.len(), 15); for (tool, sample) in samples { let redacted = redact_value(sample, Redaction::MaskParties); assert_no_known_party_name(&redacted, &known_parties, tool); @@ -1494,7 +1502,7 @@ fn empty_voucher_window_corroboration_handles_all_three_control_branches() { "20260902", None, ), - Ok((false, None)) + Ok((true, Some("nonempty_uncorroborated"))) ); assert_eq!( corroborate_empty_voucher_window( diff --git a/tools/bridge-tally-compatibility/src/lib.rs b/tools/bridge-tally-compatibility/src/lib.rs index a315fe61..acb90a33 100644 --- a/tools/bridge-tally-compatibility/src/lib.rs +++ b/tools/bridge-tally-compatibility/src/lib.rs @@ -31,19 +31,22 @@ pub const RESERVED_SURFACE_FILES: usize = 15; /// and manifest) but makes further unreviewed additions an explicit /// compatibility-surface decision. /// -/// **Raised twice, by two branches that did not see each other.** 210 to 211 on -/// master for `src-tauri/src/agent_ledgers.rs`, and 211 to 212 here for -/// `src-tauri/crates/bridge-tally-core/src/master_binding.rs`. Both reasons -/// stand and the number carries both; a merge that kept one raise and one pin -/// would pass the gate with the other file silently unpinned, which is the -/// failure this constant exists to make loud. +/// **Raised three times 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`, and 212 to 215 +/// for the voucher-presence engine plus its adapter and admission-contract +/// assertion. 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 /// the surface digest unchanged and let existing evidence attest behaviour it /// never covered. That is the deliberate decision the paragraph above requires, /// and it is one file for one named reason — not headroom. -pub const MAX_SURFACE_FILES: usize = 212; +/// The next slot binds `agent_catalog.rs`: its recursively executed proposal +/// schema changes presence admission, so existing receipts must cover its bytes. +pub const MAX_SURFACE_FILES: usize = 216; pub const MAX_OPERATIONS: usize = 16; pub const MAX_CLAIMS: usize = 128; pub const MAX_KEYS: usize = 32; @@ -61,7 +64,8 @@ const REQUIRED_SURFACE_DIRECTORIES: [&str; 2] = /// entry and resealing. A required path cannot be dropped silently, and /// `gate_rejects_each_omitted_required_lifecycle_path` iterates this list, so adding it /// here is what covers its omission. -const REQUIRED_SURFACE_FILES: [&str; 6] = [ +const REQUIRED_SURFACE_FILES: [&str; 7] = [ + "src-tauri/src/agent_catalog.rs", "src-tauri/src/agent_desktop_journal.rs", "src-tauri/src/agent_ledgers.rs", "src-tauri/src/source_draft/lifecycle.rs",