From 0a671d27a420347228d5cc35dce64d6bf193e7b6 Mon Sep 17 00:00:00 2001 From: lamemustafa Date: Wed, 22 Jul 2026 23:36:36 +0530 Subject: [PATCH] fix(tally): require parent for ledger creates --- src-tauri/crates/bridge-tally-write/src/lib.rs | 14 +++++++++++++- .../bridge-tally-write/tests/qualification.rs | 14 +++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src-tauri/crates/bridge-tally-write/src/lib.rs b/src-tauri/crates/bridge-tally-write/src/lib.rs index 6439bcb..19ef0dc 100644 --- a/src-tauri/crates/bridge-tally-write/src/lib.rs +++ b/src-tauri/crates/bridge-tally-write/src/lib.rs @@ -149,6 +149,9 @@ impl LedgerMutation { after: LedgerState, source_lineage: SourceLineage, ) -> Result { + if after.parent.is_none() { + return Err(QualificationError::CreateParentRequired); + } Self::new( LedgerOperation::Create, remote_id.into(), @@ -772,6 +775,8 @@ pub enum QualificationError { MissingIdempotencyReservation, #[error("controlled ledger alter must change at least one verified field")] NoOpMutation, + #[error("controlled ledger create requires an explicit parent")] + CreateParentRequired, #[error("clearing controlled ledger field is not yet qualified: {0}")] UnsupportedFieldClear(&'static str), #[error("preflight readback did not exactly match the declared before state")] @@ -1194,7 +1199,13 @@ mod tests { let company = SyntheticCompany::new("BRIDGE & BOOK", "company-guid").unwrap(); let mutation = LedgerMutation::create( "remote-\"<&", - LedgerState::new("LEDGER <&", None, None, Some("0".to_owned())).unwrap(), + LedgerState::new( + "LEDGER <&", + Some("PARENT <&".to_owned()), + None, + Some("0".to_owned()), + ) + .unwrap(), SourceLineage::new("synthetic", "record", "v1").unwrap(), ) .unwrap(); @@ -1203,6 +1214,7 @@ mod tests { assert!(xml.contains("remote-"<&")); assert!(xml .contains("NAME=\"LEDGER <&\" ACTION=\"Create\">LEDGER <&")); + assert!(xml.contains("PARENT <&")); } #[test] diff --git a/src-tauri/crates/bridge-tally-write/tests/qualification.rs b/src-tauri/crates/bridge-tally-write/tests/qualification.rs index 5b418d2..8129d12 100644 --- a/src-tauri/crates/bridge-tally-write/tests/qualification.rs +++ b/src-tauri/crates/bridge-tally-write/tests/qualification.rs @@ -197,7 +197,13 @@ fn alter_approval_binds_the_exact_declared_before_state() { fn prepared_import_is_deterministic_and_bytes_are_not_publicly_exposed() { let mutation = create( "bridge-&-identity", - LedgerState::new("BRIDGE ", None, None, Some("0".to_owned())).unwrap(), + LedgerState::new( + "BRIDGE ", + Some("BRIDGE SYNTHETIC GROUP".to_owned()), + None, + Some("0".to_owned()), + ) + .unwrap(), 1, ); let mut first_registry = IdempotencyRegistry::default(); @@ -448,6 +454,12 @@ fn invalid_decimal_gstin_limits_duplicates_noops_and_replays_are_rejected() { LedgerState::new("BRIDGE", None, Some("not-a-gstin".to_owned()), None).unwrap_err(), QualificationError::InvalidField("party_gstin") ); + let parentless = LedgerState::new("BRIDGE", None, None, None) + .expect("a parentless state remains usable as an alter snapshot"); + assert_eq!( + LedgerMutation::create(REMOTE_ID, parentless, lineage(1)).unwrap_err(), + QualificationError::CreateParentRequired + ); let unchanged = state("BRIDGE", "0"); assert_eq!( LedgerMutation::alter(REMOTE_ID, unchanged.clone(), unchanged.clone(), lineage(1),)