From 6eec54022884e086bd4025248d953de24638822a Mon Sep 17 00:00:00 2001 From: t Date: Tue, 15 Sep 2026 19:46:46 +0530 Subject: [PATCH] Degrade a partial window to possibly_present instead of refusing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BookWindow::observed` refused a `WindowRead::Partial` read outright (`PresenceError::WindowIncomplete`). That placement was right about the hazard and wrong about its blast radius: only `Absent` ever needed a complete window, but a construction-time refusal withheld `Present` and `PossiblyPresent` too. Since the adapter has no source-side control total for a nonempty window, every nonempty window is `Partial` — so the tool answered nothing at all over any range that held vouchers. Move the gate to where `Absent` is produced. `BookWindow` now retains its `WindowRead` and exposes `read()`; both states construct. `decide` checks it at the sole `PresenceStatus::Absent` site, last among the withholdings, and degrades a `Partial` window's would-be `Absent` to `PossiblyPresent(UndecidedReason::WindowNotProvenComplete)`. `Present` and `PossiblyPresent` are produced from a `Partial` window exactly as before. `agent_presence.rs` continues past a nonempty window instead of refusing early, and reports the window's real `read` state rather than a hardcoded "complete". `agent_catalog.rs`'s `voucher_presence` description states the new contract. That description names the reason a caller will actually read, which is not the one `safe_reason_code` returns. `UndecidedReason` serialises through `rename_all = "snake_case"`, so an item's `reason` field carries `window_not_proven_complete`; `presence_window_not_proven_complete` is the error-path spelling and appears nowhere in a successful response. The description advertises the serde form, and the schema test now asserts the anchored string and rejects the prefixed one, so the two cannot drift apart again without failing. Tests pin the invariant the type system used to buy: a `Partial` window withholds `absent` when nothing resembles a proposal, and the identical contents read `Complete` still issue it — the pair, so the first cannot pass because `Absent` broke generally. ADR 0017 is updated to match: the completeness gate is documented at `Absent`'s production rather than at construction, and the three Consequences passages asserting construction-time refusal now describe verdict-time degradation. Its measured analysis is unchanged — the source-side control total is still the only thing that would close nonempty qualification, and the widened re-read stays rejected. The ADR is not a pinned compatibility-surface file; the three touched Rust files are, and the surface and matrix are resealed here. Co-Authored-By: Claude Opus 5 --- docs/adr/0017-voucher-presence-authority.md | 92 ++++++++++++++----- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 10 +- .../bridge-tally-core/src/book_presence.rs | 63 +++++++++---- .../src/book_presence_tests.rs | 82 +++++++++++++++-- src-tauri/src/agent_catalog.rs | 2 +- src-tauri/src/agent_presence.rs | 27 +++--- src-tauri/src/agent_presence_tests.rs | 50 +++++++--- 8 files changed, 252 insertions(+), 76 deletions(-) diff --git a/docs/adr/0017-voucher-presence-authority.md b/docs/adr/0017-voucher-presence-authority.md index 284c6e57..b5948781 100644 --- a/docs/adr/0017-voucher-presence-authority.md +++ b/docs/adr/0017-voucher-presence-authority.md @@ -130,7 +130,12 @@ nothing, presented to an operator as a result. An empty candidate list means `master_binding` has to decide which of those it is reporting, and the two readings are one word apart in the output. -### 2. A window is a *claim about a window*, and it must be complete +One further withholding has a different shape from every condition above: it is +about the window rather than the proposal or its binding, and §2 states it. A +window not proven to have been read whole withholds `Absent` from every +proposal in it, however complete that proposal's own evidence was. + +### 2. A window is a *claim about a window*, and only `Absent` needs it complete `BookWindow::observed` is a boundary parse. It refuses, rather than degrades, on: @@ -144,19 +149,53 @@ on: would not have done: a status field does not neutralise the per-voucher verdict printed beside it, which is the defect this contract cites elsewhere and had reproduced here. -- **a read that was not complete** — `WindowIncomplete`. A window whose - emptiness was only partially corroborated is not "no match found". This is - the single most dangerous confusion available here, so it is a typed error - rather than a flag a caller may overlook. A window that could not be read at - all never reaches this constructor: the read fails, and the tool fails with - it. What this does **not** cover is named in the Consequences — a response - Tally answers short without saying so; - a window that does not **cover** every proposed date — `WindowDoesNotCover`. A voucher outside the window is invisible, so a verdict over it would be fiction; - a voucher dated outside the window's own range, a duplicate voucher key, an invalid range, or a window past its bound. +**Completeness of the read is not one of those refusals. It is a gate on +`Absent` alone, and it sits where `Absent` is produced.** A window declares the +`WindowRead` its source read reported — `Complete` or `Partial` — and retains +it; both construct, and `BookWindow::read()` carries the answer forward to +`decide`. The asymmetry is the point. `Present` and `PossiblyPresent` are +claims about rows that *were* read: an identity match names a row in hand, and +a resemblance names rows in hand, and neither is made stronger or weaker by +rows nobody saw. `Absent` is the only verdict that claims something about the +rows nobody saw — "not anywhere in this window" — so it is the only one that +needs the window read whole. A window whose emptiness was only partially +corroborated is not "no match found"; it is "no match found in the part that +was read", and conflating the two remains the single most dangerous confusion +available here. + +So `decide` checks `read()` at the one place `PresenceStatus::Absent` is +produced, and a `Partial` window degrades that verdict to +`PossiblyPresent(WindowNotProvenComplete)` rather than issuing it. That reason +has two spellings and they are not interchangeable: `safe_reason_code()` +returns `presence_window_not_proven_complete`, while the tool's per-item +`reason` field carries the serde spelling `window_not_proven_complete`. + +The check is **last** among the withholdings, after +`RemoteIdEvidenceUnavailable`, `ManualNumberNotSupplied` and the party +outcomes, so a proposal that already +withheld evidence of its own is reported under that more specific reason; +`WindowNotProvenComplete` is only ever reported when nothing else was missing +and the window itself is the sole reason the absence cannot be claimed. A +window that could not be read at all still never reaches the constructor: the +read fails, and the tool fails with it. What the gate does **not** cover is +named in the Consequences — a response Tally answers short without saying so. + +An earlier revision placed this gate at construction instead, refusing a +`Partial` read as `PresenceError::WindowIncomplete`. That was right about the +hazard and wrong about its blast radius: a construction-time refusal withholds +`Present` and `PossiblyPresent` too, and since the only window this adapter can +currently build over a nonempty range *is* `Partial` (see the Consequences), +the tool emitted no verdicts at all over such a range where it was entitled to +emit `present` and `possibly_present`. The rule did not change when the gate +moved — only `Absent` ever needed completeness, and only `Absent` is now +withheld for its absence. + Every verdict is therefore explicitly scoped to the window the report carries. `Absent` means *absent from this window* — it never means "absent from the book". A voucher keyed in September against an August window is not visible, @@ -204,7 +243,7 @@ Per proposed voucher, exactly one of: | --- | --- | --- | | `Present { book_key, basis, differences }` | An identity key matched, uniquely on both sides | excluding this voucher from the import | | `PossiblyPresent { reason, candidates, .. }` | Something resembles it, or something prevented a decision | **nothing** | -| `Absent` | No rule produced any candidate, in a window proven to cover it | including this voucher in the import | +| `Absent` | No rule produced any candidate, in a window proven to cover it **and** proven to have been read whole | including this voucher in the import | `PossiblyPresent` carries candidates labelled with the **rule that surfaced each** — `SharedRemoteId`, `SharedVoucherNumber`, @@ -440,9 +479,10 @@ human-approved batch — this ADR does not move. - `bridge_tally_core::book_presence` is new and is the only implementation. The MCP tool `voucher_presence` is its first consumer; it performs the existing - qualified ledger-catalogue and `vouchers` window reads, refuses to build a - window from a partial read, and shapes the report through the same party-name - marking and egress redaction as every other read result. + qualified ledger-catalogue and `vouchers` window reads, builds a window from + the read state it can actually prove — `Partial` over a nonempty range — and + shapes the report through the same party-name marking and egress redaction as + every other read result. - **Catalog coverage is byte-exact.** The typed boundary retains each observed ledger and party spelling separately from its folded resemblance key, and rejects a window whose exact spelling is absent from the catalog. A candidate @@ -511,12 +551,19 @@ human-approved batch — this ADR does not move. and would not have had one regardless. - The adapter requests the whole window before any comparison; `vouchers`' own pagination bounds output, not Tally's work. That request is not evidence that - a nonempty response is complete, so the presence adapter refuses it pending - the source-side control total below. A window past `MAX_WINDOW_VOUCHERS` is - refused with a narrow-the-range error rather than silently truncated. + a nonempty response is complete, so the presence adapter records such a window + as `Partial` pending the source-side control total below, and no `Absent` can + issue from it. A window past `MAX_WINDOW_VOUCHERS` is refused with a + narrow-the-range error rather than silently truncated. - **Nonempty window qualification is unavailable until the read has a source-side - control total.** A nonempty response is therefore represented as `Partial` - and refused at the `BookWindow` boundary; it cannot issue `Absent`. Three + control total.** A nonempty response is therefore represented as `Partial`. + It is still a window, and it still answers: `present` and `possibly_present` + are produced from it exactly as from a complete one, because neither needs + completeness. What it cannot issue is `Absent` — a proposal nothing in the + window resembled comes back `possibly_present` with reason + `window_not_proven_complete` instead. An empty window is the narrow + case the existing emptiness control can still corroborate `Complete`, and it + is therefore the only shape from which `absent` is reachable today. Three other ways a window read can go wrong are closed: a transport or source-limit failure never produces a window because the read itself fails; a malformed or short body fails the strict parse; and the paired read refuses a @@ -579,10 +626,13 @@ human-approved batch — this ADR does not move. - A prior owner-authorized, read-only replay exercised the decision rules using proposals built from observed rows. It did not establish source completeness, operational `Absent` capability, or a qualified nonempty window. The current - adapter therefore refuses a nonempty window as `presence_window_incomplete` before it - emits verdicts. The replay remains useful for controlled rule characterization - and for checking admissible perturbation seeds; it is not merge evidence for - a presence decision against a live company. + adapter therefore emits `present` and `possibly_present` verdicts from a + nonempty window but never `absent`: such a window is `Partial`, and the + verdict that would have been `absent` is reported as `possibly_present` with + reason `window_not_proven_complete`. The replay remains useful for + controlled rule characterization and for checking admissible perturbation + seeds; it is not merge evidence for a presence decision against a live + company. ## Alternatives rejected diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 6b00fd32..581bbc32 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "c7d3e0dba2066f7c86708625f66ab467330b9f90d6e13c673bae5d6ed65f7206", + "compatibility_surface_sha256": "723a605dfecf20b4e5cbb69fb18515fa34906b02bbb56244b7619353a29075a4", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 27797e96..5c312e99 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -139,7 +139,7 @@ }, { "path": "src-tauri/crates/bridge-tally-core/src/book_presence.rs", - "sha256": "5669c7ebdbfc1324760cc741ec00d8433d9601f1d9a5a063f5a9715d0bee9f25" + "sha256": "652f57e441b05f4bdb25c8e53e9bcd9000641491e2fb8479c0a6344764b2b54c" }, { "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", @@ -331,7 +331,7 @@ }, { "path": "src-tauri/src/agent_catalog.rs", - "sha256": "bbbd925062d39bd4f0997f820ea5fe3da6974deb403f0b9eb15eaad74afcc094" + "sha256": "a5e7065c0ec1a40dc3f1e08dd5ad412e067cb58fd50c85b906b0b66d7e99ffa7" }, { "path": "src-tauri/src/agent_desktop_journal.rs", @@ -347,11 +347,11 @@ }, { "path": "src-tauri/src/agent_presence.rs", - "sha256": "8c21bf41b456fcc9d748f6f80e6fc08446c3810375df21a508efc1e0b2a36414" + "sha256": "040ea6c18ddd73856298d5862b6bac3f8bc805a748d0a404e76543d39fe2f30d" }, { "path": "src-tauri/src/agent_presence_tests.rs", - "sha256": "0367c9a91f921311f16ba6ca1a7ee80572ac59c46b0ff99b0edea70c757b5da7" + "sha256": "6b8360c785405eb34168d8a4666684bb36f32fb62a4309e0786fc5958e0d08f8" }, { "path": "src-tauri/src/agent_read_profiles.rs", @@ -866,5 +866,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "c7d3e0dba2066f7c86708625f66ab467330b9f90d6e13c673bae5d6ed65f7206" + "manifest_sha256": "723a605dfecf20b4e5cbb69fb18515fa34906b02bbb56244b7619353a29075a4" } \ No newline at end of file diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence.rs b/src-tauri/crates/bridge-tally-core/src/book_presence.rs index b573aab5..85817224 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence.rs @@ -105,12 +105,6 @@ pub const MAX_TEXT_CHARS: usize = 16_384; /// before any comparison ran. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum PresenceError { - /// The window came from a read that was not complete. A window too dense - /// to read, or one whose emptiness was only partly corroborated, is not - /// "no match found" — and this is the confusion most likely to turn into a - /// duplicated invoice, so it is a type error rather than a flag. - #[error("book window was not read completely")] - WindowIncomplete, #[error("book window range was invalid")] WindowRangeInvalid, #[error("book window exceeded its bound")] @@ -187,7 +181,6 @@ impl PresenceError { /// A stable code safe to surface to an operator or a tool result. pub fn safe_reason_code(&self) -> &'static str { match self { - Self::WindowIncomplete => "presence_window_incomplete", Self::WindowRangeInvalid => "presence_window_range_invalid", Self::WindowTooLarge => "presence_window_too_large", Self::WindowLedgerMembershipsTooMany => "presence_window_ledger_memberships_too_many", @@ -238,9 +231,14 @@ pub enum RemoteIdEvidence { NotRead, } -/// How completely the window's source read observed its range. Only a complete -/// read may become a `BookWindow`; the other value exists so a caller must -/// state which it has rather than omit the question. +/// How completely the window's source read observed its range. Both values +/// become a `BookWindow` — a caller must state which it has rather than omit +/// the question — but only `Complete` may license `PresenceStatus::Absent`. +/// A window too dense to read, or one whose emptiness was only partly +/// corroborated, is not "no match found", and treating it as one is the +/// confusion most likely to turn into a duplicated invoice: `decide` degrades +/// a `Partial` window's would-be `Absent` to +/// `UndecidedReason::WindowNotProvenComplete` instead. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum WindowRead { @@ -589,13 +587,15 @@ impl ProposedBatch { } } -/// One observed window of a company's book. It can only be constructed from a -/// read that observed its whole range, so "the window was too dense to read" -/// can never reach a comparison as "nothing matched". +/// One observed window of a company's book. A window whose read was only +/// `Partial` is still admitted: `Present` and `PossiblyPresent` need no +/// completeness proof, only `Absent` does, and that gate lives at verdict +/// production (`assess`/`decide`), keyed off `read()`, rather than here. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BookWindow { from: TallyDate, to: TallyDate, + read: WindowRead, remote_id_evidence: RemoteIdEvidence, vouchers: Vec, } @@ -720,9 +720,6 @@ impl BookWindow { remote_id_evidence: RemoteIdEvidence, vouchers: Vec, ) -> Result { - if read != WindowRead::Complete { - return Err(PresenceError::WindowIncomplete); - } let from = TallyDate::parse(from.to_string()).map_err(|_| PresenceError::DateInvalid)?; let to = TallyDate::parse(to.to_string()).map_err(|_| PresenceError::DateInvalid)?; if from.as_str() > to.as_str() { @@ -771,6 +768,7 @@ impl BookWindow { Ok(Self { from, to, + read, remote_id_evidence, vouchers, }) @@ -788,6 +786,12 @@ impl BookWindow { &self.vouchers } + /// Whether this window's source read observed its whole range. Only + /// `Complete` may license `PresenceStatus::Absent`; see `decide`. + pub fn read(&self) -> WindowRead { + self.read + } + pub fn remote_id_evidence(&self) -> RemoteIdEvidence { self.remote_id_evidence } @@ -964,6 +968,13 @@ pub enum UndecidedReason { /// strongest key available to this proposal was never compared. An /// `Absent` here would rest on evidence that was not gathered. RemoteIdEvidenceUnavailable, + /// Nothing resembled the proposal, and every other decisive key was + /// either absent or already compared — but the window's own read was + /// only `Partial`. `Absent` means "not anywhere in this window", and + /// that claim is unavailable from a window not proven to cover its whole + /// declared range: what looks like "no match found" may only be "no + /// match found in the part that was read". + WindowNotProvenComplete, } impl UndecidedReason { @@ -983,6 +994,7 @@ impl UndecidedReason { Self::BookVoucherClaimedTwice => "presence_book_voucher_claimed_twice", Self::IdentityConflict => "presence_identity_conflict", Self::RemoteIdEvidenceUnavailable => "presence_remote_id_evidence_unavailable", + Self::WindowNotProvenComplete => "presence_window_not_proven_complete", } } } @@ -1044,8 +1056,11 @@ pub enum PresenceStatus { /// Something resembles it, or something prevented a decision. Authorises /// nothing. PossiblyPresent(Undecided), - /// No rule produced any candidate, in a window proven to cover it. - /// `Absent` is always relative to that window. + /// No rule produced any candidate, in a window proven to cover the + /// proposal's date **and** proven to have been read completely + /// (`WindowRead::Complete`). `Absent` is always relative to that window. + /// A window read only `Partial` degrades this to `PossiblyPresent( + /// UndecidedReason::WindowNotProvenComplete)` instead — see `decide`. Absent, } @@ -1846,6 +1861,18 @@ fn decide( BTreeSet::new(), ); } + // Every other decisive key was either absent or already compared — + // but `Absent` claims "not anywhere in this window", and that claim + // is only sound when the window's own read covered its whole range. + if window.read() != WindowRead::Complete { + return shell( + PresenceStatus::PossiblyPresent(undecided( + UndecidedReason::WindowNotProvenComplete, + (Vec::new(), 0), + )), + BTreeSet::new(), + ); + } return shell(PresenceStatus::Absent, BTreeSet::new()); } diff --git a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs index 71b7ea5e..c5d5a7f9 100644 --- a/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs +++ b/src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs @@ -233,18 +233,22 @@ fn reason(entry: &VoucherPresence) -> UndecidedReason { // --- the window is a claim about a window ------------------------------ +/// A window's completeness gate moved from construction to verdict +/// production (see `PresenceStatus::Absent`'s doc comment and `decide`): a +/// `Partial` read is still a legal `BookWindow`, and it retains its own +/// `read()` rather than having it checked once and discarded, because +/// `decide` needs it every time it would otherwise settle `Absent`. #[test] -fn a_partial_read_can_never_become_a_window() { - let error = BookWindow::observed( +fn a_partial_read_can_become_a_window_that_remembers_it_was_partial() { + let window = BookWindow::observed( "20260801", "20260831", WindowRead::Partial, RemoteIdEvidence::Observed, Vec::new(), ) - .expect_err("a partial read is not a window"); - assert_eq!(error, PresenceError::WindowIncomplete); - assert_eq!(error.safe_reason_code(), "presence_window_incomplete"); + .expect("a partial read is still a window"); + assert_eq!(window.read(), WindowRead::Partial); } #[test] @@ -1814,7 +1818,6 @@ fn a_window_bounds_aggregate_ledger_key_bytes_before_indexing() { #[test] fn every_error_carries_a_distinct_stable_reason_code() { let codes = [ - PresenceError::WindowIncomplete, PresenceError::WindowRangeInvalid, PresenceError::WindowTooLarge, PresenceError::WindowLedgerMembershipsTooMany, @@ -1837,7 +1840,7 @@ fn every_error_carries_a_distinct_stable_reason_code() { .iter() .map(PresenceError::safe_reason_code) .collect::>(); - assert_eq!(codes.len(), 19); + assert_eq!(codes.len(), 18); assert!(codes.iter().all(|code| code.starts_with("presence_"))); } @@ -2295,6 +2298,71 @@ fn the_same_proposal_is_absent_when_the_window_did_read_remote_ids() { assert!(only(&report).is_absent()); } +// --- a window not proven complete is not a window that found nothing ---- +// +// This is the safety invariant the construction-time refusal used to buy: +// `PresenceStatus::Absent` must be unreachable from a window whose `read` is +// `Partial`. The gate moved to `decide` (see `PresenceStatus::Absent`'s doc +// comment), so it is proven here instead of by the type system refusing to +// build the window at all. + +#[test] +fn a_partial_window_withholds_absent_even_when_nothing_resembles_the_proposal() { + let partial = BookWindow::observed( + "20260801", + "20260831", + WindowRead::Partial, + RemoteIdEvidence::Observed, + vec![BookRow::new("book-1", "20260819", "AA0130") + .party("Bravo Industries") + .build()], + ) + .expect("a partial read is still a window"); + let proposals = [ProposalRow::new(0, "20260812", "AA0777") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-55.00"], + ["Sales Account", "55.00"], + ]) + .build()]; + let report = run( + &partial, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + let entry = only(&report); + assert!( + !entry.is_absent(), + "the window's own read never covered its whole declared range" + ); + assert_eq!(reason(entry), UndecidedReason::WindowNotProvenComplete); +} + +/// The mirror of the test above: identical window contents and an identical +/// proposal, differing only in `WindowRead`. Without this pair, the first +/// test could pass for the wrong reason -- because `Absent` had broken +/// generally, not because `Partial` specifically withholds it. +#[test] +fn the_same_proposal_is_absent_against_the_same_contents_read_completely() { + let complete = + window(&[BookRow::new("book-1", "20260819", "AA0130").party("Bravo Industries")]); + let proposals = [ProposalRow::new(0, "20260812", "AA0777") + .party("Charlie Minerals") + .rows(vec![ + ["Charlie Minerals", "-55.00"], + ["Sales Account", "55.00"], + ]) + .build()]; + let report = run( + &complete, + &catalog(), + &numbering(NumberingMethod::Manual), + &proposals, + ); + assert!(only(&report).is_absent()); +} + #[test] fn unread_remote_id_outranks_resemblance_but_keeps_its_candidates() { let unread = BookWindow::observed( diff --git a/src-tauri/src/agent_catalog.rs b/src-tauri/src/agent_catalog.rs index de241df7..280801f1 100644 --- a/src-tauri/src/agent_catalog.rs +++ b/src-tauri/src/agent_catalog.rs @@ -308,7 +308,7 @@ pub(super) fn registered_tool_definitions(import_enabled: bool, writes_enabled: json!({"type":"object","additionalProperties":false,"required":["company_guid","from","to"],"properties":{"company_guid":{"type":"string","minLength":1},"from":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"},"to":{"type":"string","pattern":"^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$"},"voucher_type":{"type":"string","maxLength":agent_import::MAX_MASTER_NAME_CHARS},"ledger":{"type":"string","minLength":1,"maxLength":agent_import::MAX_MASTER_NAME_CHARS,"pattern":r"\S"},"offset":{"type":"integer","minimum":0,"default":0},"limit":{"type":"integer","minimum":1,"default":500}}}), ), "voucher_presence" => ( - "For a qualified complete window, answer which of 1\u{2013}500 proposed vouchers are already in the book. At present, the adapter has no source-completeness evidence for a nonempty window, so it refuses one as `presence_window_incomplete` and emits no operational presence verdict. `presence` is present, possibly_present or absent, and only `present` names a book voucher. The conditional decision basis can use a voucher number on a voucher type you declare `manual` \u{2014} unique on both sides, within an observed voucher type, and never onto a cancelled or optional voucher. It neither accepts nor reads client remote identifiers. Date, party and amount only ever produce candidates, with the rule that surfaced each and no ranking or score. Every voucher type a proposal names needs a declared numbering method; under `automatic` Tally discards the supplied number, so nothing can be decided from it. `absent` means absent from this window, so cover the dates the book could hold. Reads the full window before comparing; dense windows can fail source limits. Party names bind through the same rules as validate_masters. A reported difference on a `present` voucher is a finding for a person, not a work item: correcting a voucher by Alter or Cancel silently creates a duplicate instead (\u{00a7}9.7), and no Bridge path can correct a voucher it did not write. This never dispatches import XML to Tally.", + "Answer which of 1\u{2013}500 proposed vouchers are already in the book. `presence` is present, possibly_present or absent, and only `present` names a book voucher. The adapter has no source-completeness evidence for a nonempty window, so a nonempty window is read as `partial`; an empty window can still be corroborated complete. `present` and `possibly_present` never need a complete window and are produced either way, but `absent` means absent from the *whole* window and is only ever produced from one proven complete — a proposal that would otherwise be absent from a merely `partial` window instead comes back `possibly_present` with reason `window_not_proven_complete`. The conditional decision basis can use a voucher number on a voucher type you declare `manual` \u{2014} unique on both sides, within an observed voucher type, and never onto a cancelled or optional voucher. It neither accepts nor reads client remote identifiers. Date, party and amount only ever produce candidates, with the rule that surfaced each and no ranking or score. Every voucher type a proposal names needs a declared numbering method; under `automatic` Tally discards the supplied number, so nothing can be decided from it. `absent` means absent from this window, so cover the dates the book could hold. Reads the full window before comparing; dense windows can fail source limits. Party names bind through the same rules as validate_masters. A reported difference on a `present` voucher is a finding for a person, not a work item: correcting a voucher by Alter or Cancel silently creates a duplicate instead (\u{00a7}9.7), and no Bridge path can correct a voucher it did not write. This never dispatches import XML to Tally.", json!({"type":"object","additionalProperties":false,"required":["company_guid","from","to","numbering","vouchers"],"properties":{ "company_guid":{"type":"string","minLength":1}, "offset":{"type":"integer","minimum":0,"default":0}, diff --git a/src-tauri/src/agent_presence.rs b/src-tauri/src/agent_presence.rs index 53c44f80..59b29a32 100644 --- a/src-tauri/src/agent_presence.rs +++ b/src-tauri/src/agent_presence.rs @@ -167,15 +167,12 @@ impl Server { evidence.state = "partial"; evidence.reason_code = reason.map(str::to_string); // The adapter has no source-side cardinality for nonempty - // windows. A later catalogue reread cannot change the fixed - // `Partial` state into a complete observation, so avoid the - // extra endpoint load and fail with the evidence already in - // hand. A future qualified nonempty path can continue to the - // paired-snapshot checks below. - return Err(PresenceError::WindowIncomplete - .safe_reason_code() - .to_string() - .into()); + // windows, so `read` stays `Partial` (its default above) and + // this window can never license `Absent` (`book_presence` + // degrades that to `WindowNotProvenComplete` instead of + // refusing it). `Present` and `PossiblyPresent` need no + // completeness proof, so the read continues to the + // paired-snapshot checks below rather than refusing outright. } // The verdict is built from two independently timed observations, @@ -213,6 +210,7 @@ impl Server { let (result, truncated) = presence_result( &report, &catalogue, + read, reason, offset, limit, @@ -463,6 +461,7 @@ fn bounded_observations(mut book: Value, budget: usize) -> Value { fn presence_result( report: &PresenceReport, catalogue: &[String], + read: WindowRead, corroboration_reason: Option<&'static str>, offset: usize, limit: usize, @@ -470,6 +469,10 @@ fn presence_result( ) -> (Value, bool) { let (from, to) = report.window(); let total = report.vouchers().len(); + let read_label = match read { + WindowRead::Complete => "complete", + WindowRead::Partial => "partial", + }; // Paged like every other read in this adapter, for one reason beyond // consistency: this result shape is otherwise invisible to `page_shape`, // so an over-large report would be discarded wholesale *after* all three @@ -487,8 +490,10 @@ fn presence_result( let mut result = json!({ "profile": "agent_voucher_presence_v1", // Every verdict is relative to this window. `absent` means absent from - // this range and never absent from the book. - "window": {"from": from, "to": to, "read": "complete", "reason": corroboration_reason}, + // this range and never absent from the book, and it is only ever + // produced when `read` here is "complete" -- a "partial" window still + // yields `present`/`possibly_present`, just never `absent`. + "window": {"from": from, "to": to, "read": read_label, "reason": corroboration_reason}, "items": items, "offset": offset, "total": total, diff --git a/src-tauri/src/agent_presence_tests.rs b/src-tauri/src/agent_presence_tests.rs index 949a03da..a02462f7 100644 --- a/src-tauri/src/agent_presence_tests.rs +++ b/src-tauri/src/agent_presence_tests.rs @@ -162,7 +162,11 @@ fn the_published_schema_names_the_three_numbering_methods_and_its_bounds() { assert!(tool.get("annotations").is_none()); let description = tool["description"].as_str().expect("tool description"); assert!(description.contains("manual")); - assert!(description.contains("presence_window_incomplete")); + // The spelling matters: `safe_reason_code` returns the `presence_`-prefixed + // form, but a caller reads the serde one off an item's `reason` field, and a + // description advertising the wrong one is a string no caller can ever match. + assert!(description.contains("reason `window_not_proven_complete`")); + assert!(!description.contains("presence_window_not_proven_complete")); assert!(!description.contains("REMOTEID")); } @@ -257,7 +261,15 @@ fn a_caller_limited_presence_page_includes_its_resume_cursor() { PresenceRequest::new(&window, &catalog, &numbering, &proposals).expect("presence request"); let report = book_presence::assess(&request); - let (result, truncated) = presence_result(&report, &catalogue, None, 0, 1, 200_000); + let (result, truncated) = presence_result( + &report, + &catalogue, + WindowRead::Complete, + None, + 0, + 1, + 200_000, + ); assert!(truncated); assert_eq!(result["offset"], 0); assert_eq!(result["total"], 2); @@ -575,16 +587,17 @@ fn plans(steps: Vec) -> Vec { fn presence_plans() -> Vec { let catalogue = catalogue_xml(); let mut steps = vec![Step::Company, Step::Status, Step::Company, Step::Status]; - // Catalogue, then the voucher window. A nonempty window lacks a - // source-side cardinality control and is refused before a paired - // catalogue snapshot could contribute to a verdict. + // Catalogue, then the voucher window, then the paired-snapshot catalogue + // reread the nonempty (necessarily `Partial`) window path takes before it + // can still produce `present`/`possibly_present` verdicts. steps.extend(paired_read(&catalogue)); steps.extend(paired_read(&window_xml())); + steps.extend(paired_read(&catalogue)); plans(steps) } #[tokio::test] -async fn a_nonempty_window_without_a_control_total_refuses_to_issue_absent() { +async fn a_nonempty_window_without_a_control_total_still_answers_but_never_issues_absent() { let simulator = SequenceSimulator::spawn(presence_plans()).expect("simulator"); let directory = tempfile::tempdir().expect("directory"); let server = Server::new(Settings { @@ -618,19 +631,32 @@ async fn a_nonempty_window_without_a_control_total_refuses_to_issue_absent() { }), ) .await; - // A nonempty response has no source-side cardinality control. It therefore - // cannot issue the `Absent` verdict this fixture used to assert. - assert_eq!(response["isError"], true, "{response}"); + // A nonempty response has no source-side cardinality control, so the + // window is `Partial` and can never license `Absent` -- but `Present` and + // `PossiblyPresent` need no completeness proof, so the tool still answers + // rather than refusing the whole request the way it used to. + assert_eq!(response["isError"], false, "{response}"); + let result = &response["structuredContent"]["result"]; + assert_eq!(result["window"]["read"], "partial"); assert_eq!( - response["structuredContent"]["result"]["error"]["code"], - "presence_window_incomplete" + result["totals"], + json!({"requested": 3, "present": 2, "possibly_present": 1, "absent": 0}) + ); + let items = result["items"].as_array().expect("items"); + assert_eq!(items[0]["presence"], "present", "{items:?}"); + assert_eq!(items[1]["presence"], "present", "{items:?}"); + assert_eq!(items[2]["presence"], "possibly_present", "{items:?}"); + assert_eq!( + items[2]["reason"], "window_not_proven_complete", + "nothing resembled JV-9, but the window that found nothing was never \ + proven complete, so it must not be reported absent" ); assert_eq!( response["structuredContent"]["evidence"]["state"], "partial" ); let observed = simulator.finish().expect("requests"); - assert_eq!(observed.len(), 16); + assert_eq!(observed.len(), 22); } /// The admission contract this tool enforces lives in `agent_catalog.rs`, and