diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 60a4f488..d3667ee6 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": "c86a3689f0981f6015ddb908999701cb795bd8650330fd47d0d15414e5262986", + "compatibility_surface_sha256": "eaa41c1142c4c2652bd4fff1b748ed1ca7581fc7d5240c0478f5fc85f8baf4d8", "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 d59de4e6..5d6c63a6 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/agent_import.rs", - "sha256": "8bebc2497498c905f5eee783251b40b7083695ab958a82b17094159330a8e983" + "sha256": "47c1dc77fa33d7e4bcece1cb289e4a3535881391fe45f392e2dca0daa937930f" }, { "path": "src-tauri/src/agent_ledgers.rs", @@ -866,5 +866,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "c86a3689f0981f6015ddb908999701cb795bd8650330fd47d0d15414e5262986" + "manifest_sha256": "eaa41c1142c4c2652bd4fff1b748ed1ca7581fc7d5240c0478f5fc85f8baf4d8" } \ No newline at end of file diff --git a/src-tauri/src/agent_import.rs b/src-tauri/src/agent_import.rs index 305fd5de..a1c5996f 100644 --- a/src-tauri/src/agent_import.rs +++ b/src-tauri/src/agent_import.rs @@ -1645,10 +1645,23 @@ fn master_match_json(binding: &EntityBinding) -> Value { BindingBasis::ExactName => "exact", BindingBasis::Identifier => "identifier", }; + // A catalogue name can now hold characters the *proposal* side + // refuses -- a ledger genuinely named across two lines, say. That + // asymmetry is deliberate: the book's name is a fact, a caller's + // proposed name is input. But it means the live spelling is not + // always something the caller can send back, and the guidance below + // used to tell them to copy it regardless. Following that failed the + // whole batch on `master_name_unsafe`, because `source_entities` + // collects into one Result and refuses on the first bad name. + // + // Ask the proposal constructor rather than restating its rule, so + // the two can never disagree about what is admissible. + let importable = SourceEntity::new(binding.position, catalog_name).is_ok(); json!({ "requested": requested, "match_state": match_state, "exact_live_spelling": party_name(catalog_name.clone()), + "importable": importable, }) } BindingStatus::Ambiguous(unresolved) | BindingStatus::Unmatched(unresolved) => { @@ -1711,12 +1724,20 @@ fn master_match_json(binding: &EntityBinding) -> Value { fn master_recovery_guidance(report: &[Value]) -> String { let mut guidance = Vec::new(); + if report.iter().any(|master| { + master["match_state"] == "identifier" && master["importable"] != Value::Bool(false) + }) { + guidance + .push("For identifier-bound entries, copy exact_live_spelling from this fresh result."); + } + // Said separately, because the remedy is the opposite one: this spelling + // cannot be copied back at all, and no retry of this payload will post + // against that ledger. if report .iter() - .any(|master| master["match_state"] == "identifier") + .any(|master| master["importable"] == Value::Bool(false)) { - guidance - .push("For identifier-bound entries, copy exact_live_spelling from this fresh result."); + guidance.push("One matched ledger is named with a character imports do not accept, so its exact_live_spelling cannot be sent back; have an operator rename it in Tally, then run validate_masters again."); } if report .iter() diff --git a/src-tauri/src/agent_import_tests.rs b/src-tauri/src/agent_import_tests.rs index 394ebf72..216a4dfe 100644 --- a/src-tauri/src/agent_import_tests.rs +++ b/src-tauri/src/agent_import_tests.rs @@ -1632,6 +1632,59 @@ fn import_recovery_guidance_names_the_state_and_next_safe_read() { assert!(guidance.contains("validate_masters again before building")); } +/// A book may legitimately hold a ledger named across two lines. The catalogue +/// carries that name verbatim -- it is a fact about the book -- while the +/// proposal side still refuses a control character, because a caller's name is +/// input. The gap between those two correct rules is a spelling the tool can +/// report but the caller cannot send back. +/// +/// Telling them to copy it anyway failed the *entire* batch: `source_entities` +/// collects into one Result and refuses on the first bad name, so one such +/// ledger takes every voucher in the request down with it. +#[test] +fn a_live_spelling_imports_cannot_accept_is_not_offered_for_copying() { + let two_line = "GAMMA 5550000002\r\nSecond Line"; + let matched = one_master_match("GAMMA 5550000002", &[two_line]); + + assert_eq!( + matched["match_state"], "identifier", + "the ledger still binds on its embedded identifier" + ); + // Party names are wrapped in the egress marker, so read through it rather + // than off the field. + assert_eq!( + matched["exact_live_spelling"][super::super::PARTY_NAME_MARKER].as_str(), + Some(two_line), + "the book's own spelling is still reported verbatim, because it is what the book holds" + ); + assert_eq!( + matched["importable"], false, + "but it is marked as one the import side will refuse" + ); + + let guidance = master_recovery_guidance(&[matched]); + assert!( + !guidance.contains("copy exact_live_spelling"), + "the tool must not instruct a caller to copy a spelling that fails the whole batch" + ); + assert!( + guidance.contains("rename it in Tally"), + "and must say what the operator can actually do instead" + ); +} + +/// The ordinary identifier match is unaffected: its spelling is copyable and is +/// still offered. +#[test] +fn an_importable_live_spelling_is_still_offered_for_copying() { + let matched = one_master_match("GAMMA 5550000001", &["GAMMA (5550000001)"]); + assert_eq!(matched["match_state"], "identifier"); + assert_eq!(matched["importable"], true); + let guidance = master_recovery_guidance(&[matched]); + assert!(guidance.contains("copy exact_live_spelling")); + assert!(!guidance.contains("rename it in Tally")); +} + #[tokio::test] async fn import_bounds_distinct_ledger_names_before_tally_without_reducing_voucher_limit() { let mut repeated = payload();