Skip to content
Closed
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
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": "8b48f8f3bf66f52b41603ea85d7f25e4c2354b0fbdae31de2258e4e978666e97",
"compatibility_surface_sha256": "be2b3eea0bfc8bdaaa43f3527e56e521ab03fca15beaeded5e04d0ae47efa9c1",
"claims": [
{
"claim_id": "erp9-6-6-3-windows-education-xml-one-company",
Expand Down
12 changes: 6 additions & 6 deletions docs/tally/compatibility/compatibility-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@
},
{
"path": "src-tauri/src/agent.rs",
"sha256": "e92609482da823a1c64974ed4995a67bed4ea946b323774b524b8def35ec7b52"
"sha256": "627c2983289deb593cc7ca11b1e9ea8be98dd877f9a3876bd634ba927d6691bc"
},
{
"path": "src-tauri/src/agent_desktop_journal.rs",
Expand All @@ -339,11 +339,11 @@
},
{
"path": "src-tauri/src/agent_presence.rs",
"sha256": "1451cc20c41ea5b7dde1de8ca1bb1d42bdc96a1f8bbbee178185d9b15d4f5efa"
"sha256": "d53f0060935ef55bf636cb9746f8196bc31c9df35f7fef16eb0187adaa4a0569"
},
{
"path": "src-tauri/src/agent_presence_tests.rs",
"sha256": "886914f5848fb0d27417f289816eec5ba773b11ae17ac351a000eb190b12b3be"
"sha256": "bdef6e5fdd3878499334052a640729bafbc96462d05a11e1a50c2abd95a49370"
},
{
"path": "src-tauri/src/agent_read_profiles.rs",
Expand All @@ -359,7 +359,7 @@
},
{
"path": "src-tauri/src/agent_tests.rs",
"sha256": "310b65fb872147335baa6e7ad92bc54bc5042e39aad5db70f3f4b7d37b921597"
"sha256": "9c94efe913aeaa2ac683a6cc28ceae948ca282390682d7ee0c0930d8267f63d9"
},
{
"path": "src-tauri/src/agent_trial_balance.rs",
Expand All @@ -379,7 +379,7 @@
},
{
"path": "src-tauri/src/agent_vouchers.rs",
"sha256": "721cc3958ddb165da160255aa236c13b1581fcba477382eed5b51e2dff81a9c6"
"sha256": "f650a6ea1cc81d363fb34026669774b6196bfe2c124ea576d5246d8eb7eec188"
},
{
"path": "src-tauri/src/commands.rs",
Expand Down Expand Up @@ -858,5 +858,5 @@
"sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a"
}
],
"manifest_sha256": "8b48f8f3bf66f52b41603ea85d7f25e4c2354b0fbdae31de2258e4e978666e97"
"manifest_sha256": "be2b3eea0bfc8bdaaa43f3527e56e521ab03fca15beaeded5e04d0ae47efa9c1"
}
46 changes: 46 additions & 0 deletions src-tauri/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,52 @@ fn corroborate_empty_voucher_window(
}
}

/// Whether a **nonempty** narrow read saw every voucher its window holds.
///
/// `WindowRead::Complete` was asserted for any nonempty response, and that is
/// not something a response can be inspected for: a silently short read returns
/// rows that are all well-formed and all inside the window, and looks exactly
/// like a full one. The omitted voucher then reads as `Absent`, which is the
/// verdict that authorizes importing it a second time — the one wrong answer in
/// this engine that puts a duplicate in a client's book.
///
/// So the range is re-read a day wider and the two reads are compared on
/// Tally's own identity for the rows falling inside the original window. A row
/// the wider read saw and the narrow one did not means the narrow read was
/// short. A row carrying no `GUID` cannot be compared at all and is refused
/// rather than assumed equal, and a row that appears between the two reads
/// makes the window uncorroborated rather than absent — both fail towards
/// `Partial`, which `BookWindow::observed` turns into `WindowIncomplete`.
fn corroborate_nonempty_voucher_window(
rows: &[Value],
widened_rows: &[Value],
from: &str,
to: &str,
) -> (bool, Option<&'static str>) {
match (
window_identities(rows, from, to),
window_identities(widened_rows, from, to),
) {
(Some(observed), Some(widened)) if observed == widened => (false, None),
Comment thread
lamemustafa marked this conversation as resolved.
(Some(_), Some(_)) => (true, Some("window_short_read")),
_ => (true, Some("window_identity_unreadable")),
}
}

/// The multiset of Tally identities the rows carry inside the window, or
/// `None` if any of them carries none.
fn window_identities(rows: &[Value], from: &str, to: &str) -> Option<BTreeMap<String, usize>> {
let mut identities: BTreeMap<String, usize> = BTreeMap::new();
for row in rows.iter().filter(|row| row_in_window(row, from, to)) {
let guid = row.get("guid").and_then(Value::as_str)?;
if guid.is_empty() {
return None;
}
*identities.entry(guid.to_string()).or_insert(0) += 1;
}
Some(identities)
}

fn row_in_window(row: &Value, from: &str, to: &str) -> bool {
row.get("date")
.and_then(Value::as_str)
Expand Down
40 changes: 27 additions & 13 deletions src-tauri/src/agent_presence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,20 +124,34 @@ impl Server {
// An empty window is only an empty window once the existing
// corroboration says so. Anything less becomes `WindowIncomplete`
// at the crate boundary rather than a report full of "absent".
// Completeness is corroborated, never assumed, and that holds
// whether or not the window had rows in it. An empty read is
// checked against a wider read and the company high-water mark; a
// nonempty read is checked against a wider read on Tally's own
// identities. A silently short response looks exactly like a full
// one, and the verdict it produces -- `Absent` -- is what
// authorises importing a voucher the book already holds.
let mut read = WindowRead::Complete;
let mut reason = None;
if rows.is_empty() {
let (read_evidence, partial, corroboration) = self
.corroborate_empty_voucher_read(&identity, &company.name, &from, &to, None)
.await?;
accumulate(&mut accumulated, read_evidence);
reason = corroboration;
if partial {
read = WindowRead::Partial;
if let Some(evidence) = accumulated.as_mut() {
evidence.state = "partial";
evidence.reason_code = corroboration.map(str::to_string);
}
let (read_evidence, partial, reason) = if rows.is_empty() {
self.corroborate_empty_voucher_read(&identity, &company.name, &from, &to, None)
.await?
} else {
self.corroborate_nonempty_voucher_read(
&identity,
&company.name,
&from,
&to,
None,
&rows,
)
.await?
};
accumulate(&mut accumulated, read_evidence);
if partial {
read = WindowRead::Partial;
if let Some(evidence) = accumulated.as_mut() {
evidence.state = "partial";
evidence.reason_code = reason.map(str::to_string);
}
}

Expand Down
19 changes: 15 additions & 4 deletions src-tauri/src/agent_presence_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -513,11 +513,14 @@ fn plans(steps: Vec<Step>) -> Vec<ScenarioPlan> {
fn presence_plans() -> Vec<ScenarioPlan> {
let catalogue = catalogue_xml();
let mut steps = vec![Step::Company, Step::Status, Step::Company, Step::Status];
// Catalogue, then the voucher window, then the catalogue again: the
// verdict is built from two observations and the second read proves the
// first still holds.
// Catalogue, the voucher window, the *wider* window that corroborates it,
// then the catalogue again: the verdict is built from observations that
// each prove the one before them still holds. The wider read returns the
// same rows here, so the identities inside the window agree and the read
// is complete.
steps.extend(paired_read(&catalogue));
steps.extend(paired_read(&window_xml()));
steps.extend(paired_read(&window_xml()));
steps.extend(paired_read(&catalogue));
plans(steps)
}
Expand Down Expand Up @@ -604,7 +607,13 @@ async fn a_live_shaped_cycle_separates_present_undecided_and_absent() {
"complete"
);
let observed = simulator.finish().expect("requests");
assert_eq!(observed.len(), 22);
// Twenty-two before corroborating nonempty windows, twenty-eight after.
// The six extra are one *paired* read, which is what a corroborating read
// costs in this adapter -- company, payload, status, payload, status,
// company -- not the two that one more request would suggest. Pinned so
// the cost stays a decision rather than a drift, and pinned at what it
// measures rather than at what it was first estimated to be.
assert_eq!(observed.len(), 28);
}

/// The admission contract this tool enforces lives in `agent_catalog.rs`, and
Expand Down Expand Up @@ -739,6 +748,8 @@ async fn a_ledger_missing_from_the_catalogue_fails_closed() {
let mut steps = vec![Step::Company, Step::Status, Step::Company, Step::Status];
steps.extend(paired_read(&catalogue));
steps.extend(paired_read(&unlisted));
// The nonempty window is corroborated by a wider read before the verdict.
steps.extend(paired_read(&unlisted));
steps.extend(paired_read(&catalogue));
let simulator = SequenceSimulator::spawn(plans(steps)).expect("simulator");
let directory = tempfile::tempdir().expect("directory");
Expand Down
71 changes: 71 additions & 0 deletions src-tauri/src/agent_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1462,6 +1462,77 @@ fn voucher_window_rejects_out_of_range_rows_and_requires_a_wider_empty_check() {
);
}

#[test]
fn a_nonempty_window_is_corroborated_rather_than_assumed_complete() {
// `WindowRead::Complete` used to be asserted for any nonempty response.
// A silently short read is indistinguishable from a full one by
// inspection — every row it returns is well-formed and inside the window —
// and the voucher it omits reads as `Absent`, which is the verdict that
// authorizes importing a voucher the book already holds.
let narrow = [
json!({"date":"20260901","guid":"g-1"}),
json!({"date":"20260902","guid":"g-2"}),
];

// The wider read agrees, and rows outside the window are irrelevant to it.
assert_eq!(
corroborate_nonempty_voucher_window(
&narrow,
&[
json!({"date":"20260831","guid":"g-0"}),
json!({"date":"20260901","guid":"g-1"}),
json!({"date":"20260902","guid":"g-2"}),
json!({"date":"20260903","guid":"g-3"}),
],
"20260901",
"20260902",
),
(false, None)
);

// The wider read saw a voucher inside the window that the narrow read did
// not. That is the short read, and it must not become a verdict.
assert_eq!(
corroborate_nonempty_voucher_window(
&narrow,
&[
json!({"date":"20260901","guid":"g-1"}),
json!({"date":"20260901","guid":"g-9"}),
json!({"date":"20260902","guid":"g-2"}),
],
"20260901",
"20260902",
),
(true, Some("window_short_read"))
);

// A row carrying no identity cannot be compared, so it is refused rather
// than assumed equal — on either side.
for (rows, widened) in [
(&narrow[..], &[json!({"date":"20260901"})][..]),
(&[json!({"date":"20260901"})][..], &narrow[..]),
] {
assert_eq!(
corroborate_nonempty_voucher_window(rows, widened, "20260901", "20260902"),
(true, Some("window_identity_unreadable"))
);
}

// Same identities, different multiplicity, is still a disagreement.
assert_eq!(
corroborate_nonempty_voucher_window(
&[json!({"date":"20260901","guid":"g-1"})],
&[
json!({"date":"20260901","guid":"g-1"}),
json!({"date":"20260902","guid":"g-1"}),
],
"20260901",
"20260902",
),
(true, Some("window_short_read"))
);
}

#[test]
fn empty_voucher_window_corroboration_handles_all_three_control_branches() {
assert_eq!(
Expand Down
33 changes: 33 additions & 0 deletions src-tauri/src/agent_vouchers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,39 @@ fn accumulate_evidence(target: &mut Option<Evidence>, next: Evidence) {
}

impl Server {
/// The nonempty counterpart of `corroborate_empty_voucher_read`: one wider
/// read, compared against what the narrow read reported for the same range.
///
/// It costs a second read on every window that has vouchers in it. The empty
/// path already paid that, and the alternative is asserting completeness
/// from a response that cannot show it.
pub(super) async fn corroborate_nonempty_voucher_read(
&self,
identity: &VerifiedCompanyIdentity,
company: &str,
from: &str,
to: &str,
ledger: Option<&str>,
rows: &[Value],
) -> Result<(Evidence, bool, Option<&'static str>), ToolFailure> {
let (wider_from, wider_to) = widened_window(from, to)?;
let wider_request = render_agent_vouchers(company, &wider_from, &wider_to, None)?;
Comment thread
lamemustafa marked this conversation as resolved.
let (wider_xml, evidence) = self.post_read(identity, wider_request).await?;
let outcome = async {
let wider_rows = validate_then_filter_voucher_rows(
parse_agent_rows(&wider_xml, identity.company_guid())?,
&wider_from,
&wider_to,
ledger,
)?;
let (partial, reason) =
corroborate_nonempty_voucher_window(rows, &wider_rows, from, to);
Ok((evidence.clone(), partial, reason))
}
.await;
outcome.map_err(|failure: ToolFailure| failure.with_prior_evidence(evidence))
}

pub(super) async fn corroborate_empty_voucher_read(
&self,
identity: &VerifiedCompanyIdentity,
Expand Down