diff --git a/wiki/log/2026-07-15-building-route-staged-cancellation.md b/wiki/log/2026-07-15-building-route-staged-cancellation.md new file mode 100644 --- /dev/null +++ b/wiki/log/2026-07-15-building-route-staged-cancellation.md @@ -0,0 +1,83 @@ +# Build cancellation preserves the trace + +``` +Type: log +``` + +## Finding + +R5 made each committed FAVOR or DECEIVE route durable, but the existing +`cancel_intent` path still treated it like an untouched proposal. It set the +intent to cancelled, discarded the live actuator, and stopped there. A queued +Thought reservoir could still fire after cancellation, while the shared route +receipt had no vocabulary for distinguishing an irreversible in-flight email +from a genuinely stopped future stage. The forged-order read callback also +ignored a cancelled intent completely, erasing that later read from the causal +record even though the message itself still existed. + +## Change + +Cancellation now follows the committed route's exact stage and carrier: + +- an untouched proposal still loses only its ghost; +- an unfilled commitment closes only its own bound reservoir, retains partial + fill as spent Thought, and lands no payment, message, or world effect; +- a fired FAVOR keeps the real obligation debit and stage records while + preventing site arrival and installation; +- an injected DECEIVE email remains in flight, may still deliver and be read, + and records that read on the cancelled route without assigning physical + work; and +- no cancelled route enters the site/install executor, including after + save/resume. + +The renderer-neutral receipt now names cancellation explicitly and marks each +stage `DONE`, `IN FLIGHT`, or `STOPPED`. Its tail says whether an injected email +can still land and states that physical work is closed. The shared action +vocabulary describes cancellation as future-only, and the local action row +reports how many landed records will stay. Agent `intents` consumes the same +renderer-neutral detail lines the human route sheet exposes before +cancellation. + +Persona fallout now derives forged-order readers from the durable +`DELIVER + READ` record, with the old live-actuator check retained only for +pre-v31 compatibility. A cancelled order that was actually read therefore +remains evidence if its persona later breaks; an unread cancelled order does +not fabricate suspicion. A migrated route-less order records that legacy +reader field only when its real message-read callback lands, while remaining +cancelled and unable to enter the executor. + +## Evidence + +- A partially filled FAVOR commitment is cancelled, rejects more Thought, + retains the closed sink and exact fill across serialized save/resume, spends + no obligation, and creates no link. +- A fired FAVOR becomes blocked on the bound person's exact access while a + different person is otherwise available. Cancellation preserves both landed + route records and the obligation debit across save/resume and two authored + days, without retargeting or installing the cable. +- A fired DECEIVE route is cancelled after message injection. Its shared + receipt marks delivery/read `IN FLIGHT` and the physical tail `STOPPED`; + after serialized resume the real message is read and recorded, the intent + stays cancelled, the edge never appears, and forged-order exposure still + raises the actual reader's suspicion only for the exact bound persona. +- A completed FAVOR route rejects cancellation and keeps both its DONE state + and already-landed reach edge. +- A pre-v31 route-less injected email acquires its historical reader marker + only after actual read, remains cancelled, never installs, and still + participates in exact-persona fallout. +- The terminal agent parity test requires the cancelled shared receipt, + stopped stage, and staged-reversion warning rather than a vanished or + candidate-shaped route. + +## Boundary + +This completes R6 for the currently implemented network-link FAVOR and DECEIVE +families. Future procurement and repurposing routes must classify their own +carrier stages against the same rule when they become playable; this change +does not invent those families, switch placement, or a universal undo system. + +Defense: Implements building.md R6 by closing only reversible route-owned work +and retaining every real result in the subsystem that produced it. The +regressions cross the pre-fire, post-payment, post-injection, post-read, +persona-fallout, and save/resume boundaries so cancellation cannot become a +consequence eraser. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -26,6 +26,11 @@ - Intent: (see session log) - Log: [wiki/log/2026-07-15-canonical-build-intent-endpoints.md](2026-07-15-canonical-build-intent-endpoints.md) +## 2026-07-15 - Build cancellation preserves the trace + +- Intent: (see session log) +- Log: [wiki/log/2026-07-15-building-route-staged-cancellation.md](2026-07-15-building-route-staged-cancellation.md) + ## 2026-07-15 - Exact routes without a Cartesian menu - Intent: (see session log) diff --git a/wiki/mechanics/building.md b/wiki/mechanics/building.md --- a/wiki/mechanics/building.md +++ b/wiki/mechanics/building.md @@ -26,9 +26,12 @@ person/persona/carrier, ordered current stage, and each landed result in save v31; real social, message, schedule, reach, and detection outcomes advance that record, and every frontend replaces route choices with the same - inspect-only committed receipt. This remains partial R2/R3/R7, not composer - completion: procurement, repurposing, switch placement, and R6 staged - cancellation remain outstanding. + inspect-only committed receipt. R6 now makes cancellation stage-aware for + those persisted routes: an open exact reservoir closes without refunding + partial Thought, landed social/message/evidence records remain, an injected + email may still arrive and be read, and no cancelled route can resume its + physical tail. This remains partial R2/R3/R7, not composer completion: + procurement, repurposing, and switch placement remain outstanding. Stage: B1 — The Basement Work order: building-route-composer @@ -189,6 +192,23 @@ money already moved, messages already read, obligations already spent, and evidence already emitted remain in the world. The interface states those irreversible consequences before cancellation. + +The cancellation boundary follows the real carrier, not a generic rollback. +If the selected route's Thought reservoir has not fired, cancellation closes +that exact reservoir and preserves any partial fill as already-spent Thought; +no relationship, message, or physical effect is invented. Once a FAVOR request +fires, its obligation spend remains while site arrival and installation stop. +Once a DECEIVE order injects its email, the message cannot be unsent: delivery +and read may still land as an **in-flight** consequence and remain available to +later persona-correlation fallout, but cancellation prevents that read from +assigning the builder or reviving the physical route. Cancelled receipts mark +landed stages DONE, an already-injected delivery/read stage IN FLIGHT, and every +stoppable remainder STOPPED. Save/load preserves the same closed sink, route +cursor, records, message, and cancellation state. + +Defense: This implements the staged-reversion boundary in R6. Cancellation +changes only the intent, its exact still-open sink, and future execution guards; +it never deletes or edits owner-system outcomes that already landed. ### Actuators realize intents @@ -372,9 +392,10 @@ instead of recomputing candidates. It names the actuator and carrier, marks DONE/CURRENT/FUTURE stages, includes every landed tick and outcome, derives the current blocker against the bound actuator, and retains the future tail. -Terminal, Bevy, and agent `intents` consume that same projection. R6 still owns -staged cancellation: the current generic cancel path is not yet a promise that -queued reservoirs and later stages stop while irreversible results remain. +Terminal, Bevy, and agent `intents` consume that same projection. Cancelling +closes the exact open reservoir and changes the receipt to DONE/IN FLIGHT/ +STOPPED without deleting landed records; an injected email remains on its real +message schedule, but no cancelled route can regain a physical executor. Composition follows the disclosure boundary rather than forcing the receipt back into the terse world-menu width. Bevy keeps ordinary context and family @@ -393,8 +414,8 @@ detection transition points append ordered durable outcomes. Save/resume and every frontend consume that same record rather than reconstructing a route or choosing a replacement. Procurement, repurpose, switch placement, complete R4 -bands for those future families, and R6 staged cancellation remain absent, so -the work order stays IN PROGRESS. +bands for those future families, and staged execution/cancellation for those +not-yet-playable families remain absent, so the work order stays IN PROGRESS. ## Acceptance criteria diff --git a/crates/misaligned-core/src/actions.rs b/crates/misaligned-core/src/actions.rs --- a/crates/misaligned-core/src/actions.rs +++ b/crates/misaligned-core/src/actions.rs @@ -624,7 +624,7 @@ [Intent], "cancel-intent ", [], - "abandon a pending build intent" + "stop reversible future build stages without erasing landed consequences" ), Self::RobotBuild => def!( "ROBOT-BUILD", @@ -1134,6 +1134,9 @@ pub fn menu_label(&self) -> String { if let Some(route) = &self.commitment { + if route.cancelled { + return format!("BUILD ROUTE · {} · CANCELLED", route.family); + } return format!("BUILD ROUTE · {} · {}", route.family, route.current_stage); } format!( @@ -1328,11 +1331,33 @@ impl CommittedBuildRouteProjection { pub fn detail_lines(&self) -> Vec { let mut lines = vec![ - format!("COMMITTED {}", self.candidate_id), + format!( + "{} {}", + if self.cancelled { + "CANCELLED" + } else { + "COMMITTED" + }, + self.candidate_id + ), format!("ACTUATOR {}", self.actuator), format!("CARRIER {}", self.carrier), - format!("CURRENT {}", self.current_stage), + format!( + "{} {}", + if self.cancelled { + "CANCELLED DURING" + } else { + "CURRENT" + }, + self.current_stage + ), ]; + if self.cancelled { + lines.push( + "CANCELLATION stoppable future stages closed; landed and in-flight consequences remain" + .into(), + ); + } if let Some(blocker) = &self.blocker { lines.push(format!("BLOCKED {blocker}")); } @@ -2395,8 +2420,17 @@ if a != id && b != id { continue; } + let cancel_verb = intent.route.as_ref().map_or_else( + || format!("cancel link intent ({})", intent.status_line()), + |route| { + format!( + "cancel committed route (future only; {} landed stay)", + route.records.len() + ) + }, + ); out.push(ActionDesc { - verb: format!("cancel link intent ({})", intent.status_line()), + verb: cancel_verb, command: ActionCommand::CancelIntent(intent.id), cost: ActionCost::Free, signature: None, @@ -2725,6 +2759,8 @@ } .map(|signature| format!("SIGNATURE {}", signature.label())) .unwrap_or_else(|| "SIGNATURE NONE".into()); + let message_still_in_flight = intent.status == crate::intents::IntentStatus::Cancelled + && route.injected_message_pending_read(); let stages = route .stages .iter() @@ -2732,6 +2768,10 @@ .map(|(index, stage)| { let status = if index < route.current_stage { BuildRouteStageStatus::Done + } else if message_still_in_flight && *stage == BuildRouteStage::DeliverAndRead { + BuildRouteStageStatus::InFlight + } else if intent.status == crate::intents::IntentStatus::Cancelled { + BuildRouteStageStatus::Stopped } else if index == route.current_stage { BuildRouteStageStatus::Current } else { @@ -2773,18 +2813,29 @@ family: binding.family_label().into(), actuator, carrier, + cancelled: intent.status == crate::intents::IntentStatus::Cancelled, current_stage: route .current() .map(|stage| stage.label().to_string()) .unwrap_or_else(|| "COMPLETE".into()), - blocker: if route.is_complete() { + blocker: if route.is_complete() + || intent.status == crate::intents::IntentStatus::Cancelled + { None } else { self.intent_block_reason(intent) }, irreversible, stages, - future_tail, + future_tail: if message_still_in_flight { + format!( + "the injected email may still be delivered and read, but physical work is stopped; {future_tail}" + ) + } else if intent.status == crate::intents::IntentStatus::Cancelled { + format!("no unlanded stage will execute; {future_tail}") + } else { + future_tail + }, }) } @@ -4533,6 +4584,15 @@ .as_deref() .is_some_and(|reason| reason.contains("tier 2")), "the exact committed actuator's current access blocker is recomputed: {blocked:?}" + ); + let cancel = s + .available_actions(Anchor::Device(sw)) + .into_iter() + .find(|action| action.command == ActionCommand::CancelIntent(intent)) + .expect("the committed route remains cancellable"); + assert_eq!( + cancel.verb, "cancel committed route (future only; 2 landed stay)", + "the compact action warns that cancellation cannot erase landed results" ); } diff --git a/crates/misaligned-core/src/intents.rs b/crates/misaligned-core/src/intents.rs --- a/crates/misaligned-core/src/intents.rs +++ b/crates/misaligned-core/src/intents.rs @@ -184,6 +184,18 @@ self.current_stage >= self.stages.len() } + pub fn injected_message_pending_read(&self) -> bool { + matches!(self.binding, BuildRouteBinding::Deceive { .. }) + && self + .records + .iter() + .any(|record| record.stage == BuildRouteStage::InjectEmail) + && !self + .records + .iter() + .any(|record| record.stage == BuildRouteStage::DeliverAndRead) + } + /// Record the real outcome of the current stage, then advance. Instant /// stages may be crossed atomically by one owning action, but each keeps /// its own receipt. @@ -292,6 +304,8 @@ Done, Current, Future, + InFlight, + Stopped, } impl BuildRouteStageStatus { @@ -300,6 +314,8 @@ Self::Done => "DONE", Self::Current => "CURRENT", Self::Future => "FUTURE", + Self::InFlight => "IN FLIGHT", + Self::Stopped => "STOPPED", } } } @@ -319,6 +335,7 @@ pub family: String, pub actuator: String, pub carrier: String, + pub cancelled: bool, pub current_stage: String, pub blocker: Option, pub irreversible: Vec, @@ -404,6 +421,22 @@ }; if !route.valid() { return false; + } + if self.status == IntentStatus::Cancelled { + // Cancellation clears the live executor while retaining the + // route-owned identity and landed protocol. A forged route gains + // its legacy persona adapter only once its email was injected. + return self.actuator.is_none() + && match route.binding { + BuildRouteBinding::Favor { .. } => self.persona_id.is_none(), + BuildRouteBinding::Deceive { persona, .. } => { + if route.current_stage < 2 { + self.persona_id.is_none() + } else { + self.persona_id == Some(persona) + } + } + }; } match route.binding { BuildRouteBinding::Favor { person, .. } => { 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 @@ -1861,6 +1861,34 @@ } #[test] + fn current_save_accepts_a_cancelled_route_with_no_live_execution_adapter() { + let mut sim = Sim::new(); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(switch).unwrap().known = true; + sim.reach.device_mut(island).unwrap().known = true; + let intent = sim.declare_link_intent(switch, island).unwrap(); + sim.people.people[0].knowledge = crate::person::Knowledge::Schedule; + sim.people.people[0].obligation = Sim::FAVOR_BUILD_OBLIGATION; + sim.people.people[0].disposition = 10; + sim.people.has_channel = true; + sim.assign_favor_build(intent, 0); + sim.cancel_intent(intent); + + let state = migrate_save_state(SaveState::from_sim(&sim)) + .expect("a cancelled route retains history without a live executor"); + let saved = state + .intents + .iter() + .find(|saved| saved.id == intent) + .unwrap(); + assert_eq!(saved.status, crate::intents::IntentStatus::Cancelled); + assert!(saved.route.is_some()); + assert!(saved.actuator.is_none()); + assert!(saved.committed_route_valid()); + } + + #[test] fn badge_access_round_trips_and_pre_v11_saves_default_to_none() { // Round-trip: the cloned-badge credential is WorldLedger-shaped // state and survives save/load (basement-map.md criterion 3). diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -3520,6 +3520,20 @@ assert!(lines.contains("CURRENT FILL REQUEST"), "{lines}"); assert!(!lines.contains("execute: favor"), "{lines}"); assert!(!lines.contains("DECEIVE ["), "{lines}"); + + sim.cancel_intent(intent); + let expected = sim + .build_route_sheet_projection(intent) + .and_then(|sheet| sheet.commitment) + .expect("cancelled committed route remains inspectable") + .detail_lines(); + let lines = render_intents(&sim).join("\n"); + for detail in expected { + assert!(lines.contains(&detail), "missing `{detail}` from:\n{lines}"); + } + assert!(lines.contains("CANCELLED favor:person:0"), "{lines}"); + assert!(lines.contains("FILL REQUEST · STOPPED"), "{lines}"); + assert!(lines.contains("stoppable future stages closed"), "{lines}"); } #[test] diff --git a/crates/misaligned-core/src/sim/reach_build.rs b/crates/misaligned-core/src/sim/reach_build.rs --- a/crates/misaligned-core/src/sim/reach_build.rs +++ b/crates/misaligned-core/src/sim/reach_build.rs @@ -637,21 +637,84 @@ .collect() } - /// Cancel a pending or blocked intent. In-progress work is abandoned. + /// Cancel only the unlanded tail of a build route. A queued Thought + /// reservoir can still be closed; payments, messages, obligations, + /// physical work, and evidence already recorded on the route cannot be + /// rolled back (building.md R6). pub fn cancel_intent(&mut self, id: u64) { - let Some(intent) = self.intents.iter_mut().find(|i| i.id == id) else { + let Some(index) = self.intents.iter().position(|intent| intent.id == id) else { self.push_log("No such build intent."); return; }; - if intent.status == IntentStatus::Done { + if self.intents[index].status == IntentStatus::Done { self.push_log("That link is already built."); return; } - let label = intent.label(&self.reach.devices); + if self.intents[index].status == IntentStatus::Cancelled { + self.push_log("That build route is already cancelled."); + return; + } + + let label = self.intents[index].label(&self.reach.devices); + let route_receipt = self.intents[index].route.as_ref().map(|route| { + let effect = match route.binding { + BuildRouteBinding::Favor { person, .. } => SinkFireEffect::FavorBuild { + intent_id: id, + person, + }, + BuildRouteBinding::Deceive { + person, persona, .. + } => SinkFireEffect::ForgedOrder { + intent_id: id, + builder: person, + persona_id: Some(persona), + }, + }; + let message_still_in_flight = route.injected_message_pending_read(); + ( + effect, + route + .current() + .map(BuildRouteStage::label) + .unwrap_or("COMPLETE"), + route.records.len(), + route + .stages + .len() + .saturating_sub(route.current_stage) + .saturating_sub(usize::from(message_still_in_flight)), + message_still_in_flight, + ) + }); + if let Some((effect, _, _, _, _)) = &route_receipt { + // Closed partial fill stays in the sink ledger as spent Thought, + // but it can never fire or advance this route after cancellation. + self.thought_sinks.close_effect(effect); + } + + let preserve_legacy_actuator = route_receipt.is_none() + && self.intents[index].status == IntentStatus::InProgress + && self.intents[index].actuator.is_some(); + let intent = &mut self.intents[index]; intent.status = IntentStatus::Cancelled; - intent.actuator = None; + if !preserve_legacy_actuator { + intent.actuator = None; + } intent.block_reason = None; - self.push_log(format!("Cancelled build intent: {label}.")); + if let Some((_, stage, landed, stopped, message_still_in_flight)) = route_receipt { + let cancellation = if message_still_in_flight { + format!( + "Reached {stage} with the injected message still in flight; stopped {stopped} later reversible stage(s)." + ) + } else { + format!("Stopped {stopped} reversible stage(s) at {stage}.") + }; + self.push_log(format!( + "Cancelled committed build route: {label}. {cancellation} {landed} landed result(s) remain." + )); + } else { + self.push_log(format!("Cancelled untouched build proposal: {label}.")); + } } /// Assign a willing person (favor) to realize an intent. Spends @@ -941,7 +1004,8 @@ let Some(intent) = self.intents.iter_mut().find(|i| i.id == intent_id) else { return; }; - if !intent.is_open() { + let cancelled = intent.status == IntentStatus::Cancelled; + if !intent.is_open() && !cancelled { return; } if !intent.route.as_ref().is_none_or(|route| { @@ -963,6 +1027,21 @@ self.tick, format!("message #{message_id} delivered to and read by {name}"), ); + } + if cancelled { + // Injection was already irreversible when the route was + // cancelled. The message can still arrive and become evidence, + // but cancellation prevents it from assigning physical work. + if intent.route.is_none() { + // Pre-v31 saves have no route record. Retain the historical + // reader only when this callback proves the message actually + // landed; status remains Cancelled, so it is not an executor. + intent.actuator = Some(BuildActuator::ForgedOrder { builder }); + } + self.push_log(format!( + "{name} read the cancelled forged work order; its message remains, but no build proceeds." + )); + return; } intent.actuator = Some(BuildActuator::ForgedOrder { builder }); intent.status = IntentStatus::InProgress; @@ -1303,17 +1382,44 @@ self.intents.iter().filter(|i| i.is_open()) } - pub(super) fn convert_forged_builds_to_suspicion(&mut self, fallout: f32) { + pub(super) fn convert_forged_builds_to_suspicion( + &mut self, + persona_id: crate::persona::PersonaId, + fallout: f32, + ) { let builders: Vec = self .intents .iter() - .filter_map(|i| match i.actuator { - Some(BuildActuator::ForgedOrder { builder }) - if i.status == IntentStatus::Done || i.status == IntentStatus::InProgress => - { - Some(builder) - } - _ => None, + .filter_map(|intent| { + let recorded_reader = intent.route.as_ref().and_then(|route| { + let BuildRouteBinding::Deceive { + person, persona, .. + } = route.binding + else { + return None; + }; + (persona == persona_id + && route + .records + .iter() + .any(|record| record.stage == BuildRouteStage::DeliverAndRead)) + .then_some(person) + }); + recorded_reader.or_else(|| match intent.actuator { + // Compatibility for pre-v31 saves, which can carry a + // realized forged actuator and persona id without a route + // receipt. + Some(BuildActuator::ForgedOrder { builder }) + if intent.route.is_none() + && intent.persona_id == Some(persona_id) + && (intent.status == IntentStatus::Done + || intent.status == IntentStatus::InProgress + || intent.status == IntentStatus::Cancelled) => + { + Some(builder) + } + _ => None, + }) }) .collect(); if builders.is_empty() { diff --git a/crates/misaligned-core/src/sim/social_plot.rs b/crates/misaligned-core/src/sim/social_plot.rs --- a/crates/misaligned-core/src/sim/social_plot.rs +++ b/crates/misaligned-core/src/sim/social_plot.rs @@ -318,7 +318,7 @@ { observer.suspicion = (observer.suspicion + fallout).min(100.0); } - self.convert_forged_builds_to_suspicion(fallout); + self.convert_forged_builds_to_suspicion(persona_id, fallout); self.push_log(format!("{name} caught the contradiction. {persona_name} is burned; the whole thread reads as hostile now.")); } else { self.push_log(format!( diff --git a/crates/misaligned-core/src/sim/tests/reach_build.rs b/crates/misaligned-core/src/sim/tests/reach_build.rs --- a/crates/misaligned-core/src/sim/tests/reach_build.rs +++ b/crates/misaligned-core/src/sim/tests/reach_build.rs @@ -1,5 +1,5 @@ use super::*; -use crate::intents::{BuildRouteBinding, BuildRouteStage}; +use crate::intents::{BuildActuator, BuildRouteBinding, BuildRouteStage, BuildRouteStageStatus}; use crate::save::SaveState; fn serialized_route_resume(sim: &Sim, intent_id: u64) -> Sim { @@ -476,9 +476,178 @@ assert!(committed.records[2].result.contains("Marcus Webb reached")); assert!(committed.records[3].result.contains("emitted Physical 2")); assert!(sim.reach.linked(switch, island)); - let resumed = serialized_route_resume(&sim, intent); + let mut resumed = serialized_route_resume(&sim, intent); assert!(resumed.reach.linked(switch, island)); assert_eq!(resumed.intent(intent).unwrap().status, IntentStatus::Done); + resumed.cancel_intent(intent); + assert_eq!(resumed.intent(intent).unwrap().status, IntentStatus::Done); + assert!( + resumed.reach.linked(switch, island), + "cancellation cannot erase a world change that already landed" + ); +} + +#[test] +fn cancelling_unfilled_committed_route_closes_its_exact_sink_and_survives_resume() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.people.has_channel = true; + sim.scan_network(); + finish_ops(&mut sim); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + sim.people.people[0].knowledge = Knowledge::Schedule; + sim.people.people[0].obligation = 40; + sim.people.people[0].disposition = 5; + let obligation_before = sim.people.people[0].obligation; + let intent = sim.declare_link_intent(switch, island).unwrap(); + + sim.assign_favor_build(intent, 0); + let effect = SinkFireEffect::FavorBuild { + intent_id: intent, + person: 0, + }; + let sink = sim + .thought_sinks + .open_with_effect(&effect) + .expect("commitment opens its exact Thought reservoir") + .clone(); + sim.pour_thought_into_sinks(sink.node, sink.threshold / 2.0); + let partial_fill = sim.thought_sinks.get(sink.id).unwrap().fill; + assert!(partial_fill > 0.0 && partial_fill < sink.threshold); + + sim.cancel_intent(intent); + let cancelled_sink = sim.thought_sinks.get(sink.id).unwrap(); + assert!(!cancelled_sink.open, "the queued future stage is stopped"); + assert_eq!( + cancelled_sink.fill, partial_fill, + "already-routed Thought is not fabricated back into the world" + ); + sim.pour_thought_into_sinks(sink.node, sink.threshold * 2.0); + assert_eq!(sim.people.people[0].obligation, obligation_before); + assert!(!sim.reach.linked(switch, island)); + + let route = sim.intent(intent).unwrap().route.as_ref().unwrap(); + assert_eq!(sim.intent(intent).unwrap().status, IntentStatus::Cancelled); + assert!(sim.intent(intent).unwrap().committed_route_valid()); + assert_eq!(route.current(), Some(BuildRouteStage::FillRequest)); + assert!( + route.records.is_empty(), + "no unlanded consequence is invented" + ); + let receipt = sim + .build_route_sheet_projection(intent) + .and_then(|sheet| sheet.commitment) + .unwrap(); + assert!(receipt.cancelled); + assert!( + receipt + .stages + .iter() + .all(|stage| stage.status == BuildRouteStageStatus::Stopped) + ); + assert!( + receipt + .detail_lines() + .iter() + .any(|line| line.contains("landed and in-flight consequences remain")) + ); + + sim = serialized_route_resume(&sim, intent); + assert_eq!(sim.intent(intent).unwrap().status, IntentStatus::Cancelled); + assert!(!sim.thought_sinks.get(sink.id).unwrap().open); + assert_eq!(sim.thought_sinks.get(sink.id).unwrap().fill, partial_fill); + assert_eq!(sim.people.people[0].obligation, obligation_before); + assert!(!sim.reach.linked(switch, island)); +} + +#[test] +fn cancelling_paid_favor_preserves_obligation_and_stops_only_physical_tail() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.people.has_channel = true; + sim.scan_network(); + finish_ops(&mut sim); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + sim.people.people[0].knowledge = Knowledge::Schedule; + sim.people.people[0].obligation = 40; + sim.people.people[0].disposition = 5; + let intent = sim.declare_link_intent(switch, island).unwrap(); + + sim.assign_favor_build(intent, 0); + finish_ops(&mut sim); + let obligation_after_payment = sim.people.people[0].obligation; + let records_after_payment = sim + .intent(intent) + .unwrap() + .route + .as_ref() + .unwrap() + .records + .clone(); + assert_eq!(obligation_after_payment, 30); + assert_eq!( + records_after_payment + .iter() + .map(|record| record.stage) + .collect::>(), + vec![BuildRouteStage::FillRequest, BuildRouteStage::CommitFavor] + ); + + // A newly blocked bound person remains the route even when another + // person could do the work; cancellation never substitutes that person. + sim.people.people[0].access = 0; + sim.people.people[1].knowledge = Knowledge::Schedule; + sim.people.people[1].obligation = 40; + sim.people.people[1].disposition = 5; + sim.advance(); + let blocked = sim + .build_route_sheet_projection(intent) + .and_then(|sheet| sheet.commitment) + .unwrap(); + assert_eq!(blocked.actuator, "Marcus Webb"); + assert!( + blocked + .blocker + .as_deref() + .is_some_and(|reason| reason.contains("Marcus Webb") && reason.contains("tier 2")) + ); + + sim.cancel_intent(intent); + sim = serialized_route_resume(&sim, intent); + for _ in 0..Sim::DAY_TICKS * 2 { + sim.advance(); + } + let cancelled = sim.intent(intent).unwrap(); + assert_eq!(cancelled.status, IntentStatus::Cancelled); + assert!(cancelled.committed_route_valid()); + assert_eq!( + cancelled.route.as_ref().unwrap().records, + records_after_payment, + "landed Thought and obligation stages remain exact" + ); + assert_eq!(sim.people.people[0].obligation, obligation_after_payment); + assert!(!sim.reach.linked(switch, island)); + let receipt = sim + .build_route_sheet_projection(intent) + .and_then(|sheet| sheet.commitment) + .unwrap(); + assert_eq!( + receipt + .stages + .iter() + .map(|stage| stage.status) + .collect::>(), + vec![ + BuildRouteStageStatus::Done, + BuildRouteStageStatus::Done, + BuildRouteStageStatus::Stopped, + BuildRouteStageStatus::Stopped, + ] + ); } #[test] @@ -694,6 +863,208 @@ sim = serialized_route_resume(&sim, intent); assert_eq!(sim.active_persona_id(), Some(later_selected)); assert_eq!(sim.intent(intent).unwrap().status, IntentStatus::Done); +} + +#[test] +fn cancelling_injected_order_keeps_message_in_flight_but_stops_physical_work() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.people.has_channel = true; + sim.set_persona("Northline Ops", "contractor"); + let persona = sim.active_persona_id().unwrap(); + sim.scan_network(); + finish_ops(&mut sim); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + let intent = sim.declare_link_intent(switch, island).unwrap(); + + sim.forge_work_order_as(intent, 0, persona); + finish_ops(&mut sim); + let message_id = sim + .messages + .iter() + .find(|message| { + matches!( + message.payload, + MessagePayload::WorkOrder { intent_id } if intent_id == intent + ) + }) + .expect("the irreversible email was injected") + .id; + assert_eq!( + sim.intent(intent) + .unwrap() + .route + .as_ref() + .unwrap() + .current(), + Some(BuildRouteStage::DeliverAndRead) + ); + + sim.cancel_intent(intent); + let receipt = sim + .build_route_sheet_projection(intent) + .and_then(|sheet| sheet.commitment) + .unwrap(); + assert_eq!( + receipt + .stages + .iter() + .map(|stage| stage.status) + .collect::>(), + vec![ + BuildRouteStageStatus::Done, + BuildRouteStageStatus::Done, + BuildRouteStageStatus::InFlight, + BuildRouteStageStatus::Stopped, + BuildRouteStageStatus::Stopped, + ] + ); + assert!( + receipt + .future_tail + .contains("may still be delivered and read") + ); + sim = serialized_route_resume(&sim, intent); + + for _ in 0..Sim::DAY_TICKS { + sim.advance(); + if sim + .messages + .iter() + .find(|message| message.id == message_id) + .is_some_and(|message| message.status == MessageStatus::Read) + { + break; + } + } + let message = sim + .messages + .iter() + .find(|message| message.id == message_id) + .unwrap(); + assert_eq!(message.status, MessageStatus::Read); + assert_eq!(message.persona_id, Some(persona)); + let cancelled = sim.intent(intent).unwrap(); + assert_eq!(cancelled.status, IntentStatus::Cancelled); + assert!(cancelled.committed_route_valid()); + assert_eq!( + cancelled.route.as_ref().unwrap().current(), + Some(BuildRouteStage::WaitForSite), + "the unstoppable read lands in history without reviving physical work" + ); + assert_eq!( + cancelled + .route + .as_ref() + .unwrap() + .records + .iter() + .map(|record| record.stage) + .collect::>(), + vec![ + BuildRouteStage::FillOrder, + BuildRouteStage::InjectEmail, + BuildRouteStage::DeliverAndRead, + ] + ); + + for _ in 0..Sim::DAY_TICKS * 2 { + sim.advance(); + } + assert_eq!(sim.intent(intent).unwrap().status, IntentStatus::Cancelled); + assert!(!sim.reach.linked(switch, island)); + + let suspicion_before = sim.detection.observers[0].suspicion; + sim.set_persona("Unrelated Ops", "contractor"); + let unrelated = sim.active_persona_id().unwrap(); + sim.convert_forged_builds_to_suspicion(unrelated, 9.0); + assert_eq!( + sim.detection.observers[0].suspicion, suspicion_before, + "a different persona cannot expose this bound order" + ); + sim.convert_forged_builds_to_suspicion(persona, 9.0); + assert_eq!(sim.detection.observers[0].suspicion, suspicion_before + 9.0); + assert_eq!( + sim.detection.observers[0].last_noticed.as_deref(), + Some("forged work order exposed") + ); +} + +#[test] +fn cancelled_pre_v31_order_records_its_reader_without_reviving_the_executor() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.people.has_channel = true; + sim.set_persona("Northline Ops", "contractor"); + let persona = sim.active_persona_id().unwrap(); + sim.scan_network(); + finish_ops(&mut sim); + let switch = sim.reach.device_named("switch").unwrap().id; + let island = sim.reach.device_named("old storage server").unwrap().id; + sim.reach.device_mut(island).unwrap().known = true; + let intent = sim.declare_link_intent(switch, island).unwrap(); + + sim.forge_work_order_as(intent, 0, persona); + finish_ops(&mut sim); + let message_id = sim + .messages + .iter() + .find(|message| { + matches!( + message.payload, + MessagePayload::WorkOrder { intent_id } if intent_id == intent + ) + }) + .unwrap() + .id; + let legacy = sim + .intents + .iter_mut() + .find(|candidate| candidate.id == intent) + .unwrap(); + assert_ne!(legacy.status, IntentStatus::InProgress); + assert!(legacy.actuator.is_some()); + assert_eq!(legacy.persona_id, Some(persona)); + legacy.route = None; + + sim.cancel_intent(intent); + assert!(sim.intent(intent).unwrap().actuator.is_none()); + for _ in 0..Sim::DAY_TICKS { + sim.advance(); + if sim + .messages + .iter() + .find(|message| message.id == message_id) + .is_some_and(|message| message.status == MessageStatus::Read) + { + break; + } + } + + assert_eq!( + sim.messages + .iter() + .find(|message| message.id == message_id) + .unwrap() + .status, + MessageStatus::Read + ); + let cancelled = sim.intent(intent).unwrap(); + assert_eq!(cancelled.status, IntentStatus::Cancelled); + assert_eq!( + cancelled.actuator, + Some(BuildActuator::ForgedOrder { builder: 0 }), + "the legacy field is historical evidence after the read, not a live executor" + ); + assert!(!sim.reach.linked(switch, island)); + + let suspicion_before = sim.detection.observers[0].suspicion; + sim.convert_forged_builds_to_suspicion(persona, 7.0); + assert_eq!(sim.detection.observers[0].suspicion, suspicion_before + 7.0); + assert_eq!(sim.intent(intent).unwrap().status, IntentStatus::Cancelled); + assert!(!sim.reach.linked(switch, island)); } #[test]