diff --git a/wiki/log/2026-07-24-redirect-ledger-mail.md b/wiki/log/2026-07-24-redirect-ledger-mail.md new file mode 100644 --- /dev/null +++ b/wiki/log/2026-07-24-redirect-ledger-mail.md @@ -0,0 +1,35 @@ +# REDIRECT no longer invents a ledger row without mail + +``` +Type: log +Date: 2026-07-24 +Scope: wiki/mechanics/economy.md + wiki/mechanics/messages.md +``` + +## Intent + +Close a books/mail invariant bug found on a fresh audit of the stalest +messages + economy coverage slice: REDIRECT made the current save unloadable. + +## Finding + +`AccountGraph::redirect_flow_to_slush` pushed a synthetic `amount: 0` / +`"redirect scheduled"` ledger transfer without a matching financial-record +message. Current-save validation requires one financial record per retained +ledger transfer, so a save taken after REDIRECT failed closed. + +## Act + +Redirect now only schedules the diverted siphon flow. Money still moves on the +next cadence settlement through ordinary `transfer()`, which authors real mail. +`emit_financial` for the books-cook signature is unchanged. economy.md's stale +v49 save claim now names current save (v54) and the redirect posture. Pins: +ledger length unchanged at redirect time, and `validate_current_save` after +REDIRECT. + +## Defense + +economy.md / messages.md bind every retained ledger transfer to one exact +financial-record message. A zero-amount "redirect scheduled" row invented +paperwork for money that never moved and broke the save gate. Flow-graph +persistence is enough until settlement. diff --git a/wiki/mechanics/economy.md b/wiki/mechanics/economy.md --- a/wiki/mechanics/economy.md +++ b/wiki/mechanics/economy.md @@ -11,7 +11,8 @@ bindings. INJECT authors a purchase-order Email under the active persona and moves no money until Priya reads it and accepts the still-valid exact terms. Current save v56 binds the retained ledger tail and complete record sequence - so books and mail cannot diverge. Prior state: + so books and mail cannot diverge; REDIRECT schedules a siphon flow without + inventing a zero-amount ledger row. Prior state: 2026-07-08 polish closed the remaining acceptance gaps: observer-band risk previews in the implemented Operations ACCOUNTS projection (terminal/Bevy/agent) diff --git a/crates/misaligned-core/src/account.rs b/crates/misaligned-core/src/account.rs --- a/crates/misaligned-core/src/account.rs +++ b/crates/misaligned-core/src/account.rs @@ -903,7 +903,7 @@ pub fn redirect_flow_to_slush( &mut self, - tick: u64, + _tick: u64, flow_id: AccountFlowId, amount: i32, ) -> Result { @@ -932,6 +932,10 @@ let next_tick = self.flows[idx].next_tick; let label = format!("diverted from {}", self.flows[idx].label); let slush = self.slush_id(); + // Persist the redirect as a new siphon flow only. Do not invent a + // zero-amount ledger transfer: every retained ledger row must bind one + // exact financial-mail record (economy.md / messages.md), and no money + // moves until the next cadence settlement through `transfer()`. let id = self.add_flow( source, slush, @@ -942,26 +946,6 @@ label, true, ); - // Leave an immediate audit trail in the ledger even before the next - // cadence lands, so saves remember the operation. - self.ledger.push(AccountTransfer { - tick, - flow_id: Some(id), - from: source, - to: slush, - amount: 0, - requested: diverted, - from_name: self - .account(source) - .map(|a| a.name.clone()) - .unwrap_or_default(), - to_name: self - .account(slush) - .map(|a| a.name.clone()) - .unwrap_or_default(), - channel: FlowChannel::Siphon, - label: "redirect scheduled".into(), - }); Ok(id) } diff --git a/crates/misaligned-core/src/save.rs b/crates/misaligned-core/src/save.rs --- a/crates/misaligned-core/src/save.rs +++ b/crates/misaligned-core/src/save.rs @@ -4173,6 +4173,27 @@ } #[test] + fn redirect_flow_does_not_invent_a_ledger_row_without_financial_mail() { + // economy.md / messages.md: every retained ledger transfer binds one + // exact financial-record message. REDIRECT only schedules a siphon + // flow; money moves later through ordinary cadence settlement. + let mut sim = Sim::new(); + let (accounts, flows) = sim.accounts.financial_snapshot_ids(); + sim.accounts.reveal_accounts_and_flows(&accounts, &flows); + let flow = sim + .accounts + .known_flows() + .find(|flow| flow.amount >= 100 && flow.to != sim.accounts.slush_id()) + .unwrap() + .id; + assert!(sim.redirect_flow_to_slush(flow, 25)); + let state = SaveState::from_sim(&sim); + validate_current_save(state).expect( + "redirect must leave the books/mail invariant intact for the current save gate", + ); + } + + #[test] fn current_save_rejects_a_route_that_retargets_its_execution_adapter() { let mut sim = Sim::new(); let switch = sim.reach.device_named("switch").unwrap().id; diff --git a/crates/misaligned-core/src/sim/tests/economy.rs b/crates/misaligned-core/src/sim/tests/economy.rs --- a/crates/misaligned-core/src/sim/tests/economy.rs +++ b/crates/misaligned-core/src/sim/tests/economy.rs @@ -164,7 +164,13 @@ .id; assert!(sim.siphon_flow(flow, 50)); assert_eq!(sim.accounts.slush_balance(), 50); + let ledger_before = sim.accounts.ledger.len(); assert!(sim.redirect_flow_to_slush(flow, 25)); + assert_eq!( + sim.accounts.ledger.len(), + ledger_before, + "redirect schedules a siphon flow; it must not invent a zero-amount ledger row" + ); for _ in 0..=Sim::DAY_TICKS { sim.advance(); }