Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 71 additions & 21 deletions docs/adr/0017-voucher-presence-authority.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/tally/compatibility/compatibility-matrix.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 5 additions & 5 deletions docs/tally/compatibility/compatibility-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -331,7 +331,7 @@
},
{
"path": "src-tauri/src/agent_catalog.rs",
"sha256": "bbbd925062d39bd4f0997f820ea5fe3da6974deb403f0b9eb15eaad74afcc094"
"sha256": "a5e7065c0ec1a40dc3f1e08dd5ad412e067cb58fd50c85b906b0b66d7e99ffa7"
},
{
"path": "src-tauri/src/agent_desktop_journal.rs",
Expand All @@ -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",
Expand Down Expand Up @@ -866,5 +866,5 @@
"sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a"
}
],
"manifest_sha256": "c7d3e0dba2066f7c86708625f66ab467330b9f90d6e13c673bae5d6ed65f7206"
"manifest_sha256": "723a605dfecf20b4e5cbb69fb18515fa34906b02bbb56244b7619353a29075a4"
}
63 changes: 45 additions & 18 deletions src-tauri/crates/bridge-tally-core/src/book_presence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<BookVoucher>,
}
Expand Down Expand Up @@ -720,9 +720,6 @@ impl BookWindow {
remote_id_evidence: RemoteIdEvidence,
vouchers: Vec<BookVoucher>,
) -> Result<Self, PresenceError> {
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() {
Expand Down Expand Up @@ -771,6 +768,7 @@ impl BookWindow {
Ok(Self {
from,
to,
read,
remote_id_evidence,
vouchers,
})
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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",
}
}
}
Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -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());
}

Expand Down
Loading