From fe482d7d7452b53b697e1cf42d7c02b534e81066 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 15 Jul 2026 20:41:19 +0000 Subject: [PATCH] Keep committed build routes causal through save. Persist the exact bound actuator, carrier, ordered stages, and landed outcomes so every frontend can inspect one no-retargeting plan across resume. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- wiki/log/2026-07-15-building-route-commitment.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ wiki/log/DEVLOG.md | 5 +++++ wiki/mechanics/building.md | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------ crates/misaligned-bevy/src/main.rs | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++------- crates/misaligned-core/src/actions.rs | 316 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------- crates/misaligned-core/src/intents.rs | 247 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ crates/misaligned-core/src/save.rs | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- crates/misaligned-terminal/src/agent.rs | 38 ++++++++++++++++++++++++++++++++++++++ crates/misaligned-terminal/src/main.rs | 26 +++++++++++++++++++++++++- crates/misaligned-core/src/sim/communications.rs | 2 +- crates/misaligned-core/src/sim/reach_build.rs | 243 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------- crates/misaligned-core/src/sim/tests/reach_build.rs | 218 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 12 file(s) changed, 1304 insertion(s)(+), 84 deletion(s)(-) diff --git a/wiki/log/2026-07-15-building-route-commitment.md b/wiki/log/2026-07-15-building-route-commitment.md new file mode 100644 --- /dev/null +++ b/wiki/log/2026-07-15-building-route-commitment.md @@ -0,0 +1,79 @@ +# Build routes keep one causal thread + +``` +Type: log +``` + +## Finding + +The complete route preview still disappeared at the moment that mattered. +Executing FAVOR or DECEIVE opened the existing Thought reservoir, but the +intent persisted only its eventual actuator. The selected candidate, exact +email carrier, ordered stage, and already-landed results were not durable. +After save/load the simulation could continue its baseline effect, but no +shared receipt could prove which causal plan the player had committed to or +where it had reached. Route candidates also remained a live query, which is +the wrong surface after an irreversible choice. + +## Change + +Save v31 adds one optional `BuildRouteCommitment` to each intent. Commitment +stores the stable candidate id, exact FAVOR person or DECEIVE person/persona, +exact carrier device, the family's ordered stages, a current-stage cursor, and +append-only landed records. Old saves migrate with no route: pre-v31 bytes did +not contain enough information to reconstruct one without guessing. + +The cursor advances only at ordinary causal transition points: + +- FAVOR records the real Thought fill and relationship-obligation spend, then + the person's real endpoint arrival and the reach/signature result; +- DECEIVE records the real Thought fill, exact message injection, delivery and + read by the bound person, then endpoint arrival and the reach/witness/ + signature result. + +The effect guards compare their exact person, persona, and carrier to the +durable binding. Changing the selected persona or making another email carrier +preferable cannot retarget queued work. + +## Player surface + +Once committed, the shared route sheet contains no executable candidates. One +inspect-only receipt names the bound actuator and carrier, marks each stage +DONE/CURRENT/FUTURE, shows the exact current blocker and future tail, and keeps +every landed tick and outcome visible. Terminal and Bevy consume the same +`HumanMenuRow`; agent `intents` emits the same complete detail lines instead +of silently printing an empty family list. + +## Evidence + +- FAVOR and DECEIVE fixtures serialize to JSON, deserialize, and apply to a + fresh simulation at every reachable stage boundary, then resume through the + real schedule/message path to the same completed edge. +- The FAVOR test pins the exact obligation spend, endpoint arrival, Physical + outcome, and complete ordered record. +- The DECEIVE test selects another persona and inserts a newly preferred email + carrier after commitment; the injected message and every later stage remain + on the original person, persona, and carrier through save/resume. Its bound + person first reads email away from either endpoint, making delivery/read and + site arrival independently serializable boundaries rather than one atomic + test tick. +- A v30 migration fixture proves old intents gain no fabricated route, while + current-version validation rejects a route whose legacy live actuator or + persona execution adapter disagrees with the persisted binding. +- Shared projection and agent-protocol tests require that candidates disappear + after commitment, the receipt remains inspectable, and a changed access + condition recomputes one blocker against the bound person without offering a + substitute. +- Bevy's layout contract keeps the committed form on the same 880-pixel route + sheet even when it is reopened from the family root, and pins the complete + card within the supported 960x540 height. Exact `build-committed-route` + captures at 1280x720 and 960x540 were read after capture: both show one calm + inspect-only FAVOR plan, all four ordered stages, the current reservoir-fill + stage, and the complete tail without clipping or false candidate choices. + +## Boundary + +This implements R5 for the existing network-link FAVOR and DECEIVE families. +It does not add procurement, repurposing, switch placement, or R6 staged +cancellation. In particular, the old generic cancel path is not yet upgraded +to stop queued future stages while preserving already-landed consequences. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -36,6 +36,11 @@ - Intent: (see session log) - Log: [wiki/log/2026-07-15-building-route-full-preview.md](2026-07-15-building-route-full-preview.md) +## 2026-07-15 - Build routes keep one causal thread + +- Intent: (see session log) +- Log: [wiki/log/2026-07-15-building-route-commitment.md](2026-07-15-building-route-commitment.md) + ## 2026-07-15 - One build ghost truth - 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 @@ -13,17 +13,21 @@ the current world could realize it — procurement, a favor from one exact person, deception through one exact persona, repurposing one exact object, and later a robot — with requirements, costs, signatures, blockers, and - future tail before commitment. The existing baseline remains live; the - composer and multi-stage route state are not yet implemented. The 2026-07-15 - first composer foundation now projects every baseline intent as one + future tail before commitment. The existing baseline remains live. The + 2026-07-15 first composer foundation now projects every baseline intent as one renderer-neutral recipe/lifecycle/geometry receipt consumed by terminal, Bevy, and agent mode. The second foundation adds a renderer-neutral, family-first route sheet for the existing network-link FAVOR and DECEIVE paths: earned people become exact candidates, each DECEIVE route binds one exact usable persona instance, terminal and Bevy traverse the same page - tree, and agent `intents` prints the same ids and bound execution. This is a - partial R2/R3/R7 seam, not completion: procurement, repurposing, switch - placement, full stage/observer previews, persisted commitment, and staged + tree, and agent `intents` prints the same ids and bound execution. The R4 + slice adds the complete shared requirement/cost/stage/signature/blocker/tail + preview. R5 then persists the selected FAVOR or DECEIVE candidate, exact + 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. Stage: B1 — The Basement @@ -337,10 +341,40 @@ own candidate from ready to the exact queue-conflict blocker. This completes the R4 receipt contract for the current FAVOR and DECEIVE route -families without new save state. Procurement, repurposing, and later route -families must populate the same required bands before they can surface. R5/R6 -still own persisted commitment/current-stage state, save/resume, and staged -cancellation; an uncommitted preview does not pretend those exist. +families. Procurement, repurposing, and later route families must populate the +same required bands before they can surface. + +### Persisted committed route (implemented R5) + +Executing one current FAVOR or DECEIVE candidate is the commitment boundary. +Save v31 pins the candidate id, exact person, exact persona where applicable, +the exact email-carrier device, the family's ordered stages, the current stage, +and one tick/result record for every landed stage. The live network-link plans +are: + +- FAVOR: fill the request reservoir → spend the exact person's obligation and + bind the favor → wait for that person's authored schedule at an endpoint → + install the link through the ordinary reach and Physical-signature path; and +- DECEIVE: fill the forged-order reservoir → inject one exact persona-bound + email → wait for that message to deliver and be read by the exact person → + wait for the person's authored schedule at an endpoint → install through the + ordinary reach, witness, persona-integrity, and Physical-signature path. + +The transition record is written where each owning subsystem records the real +outcome, not by a parallel timer. Stages which are one atomic owning action may +land on the same tick but remain separate ordered receipts. Save/load at every +reachable boundary resumes that exact plan. A new selected persona, newly +preferred email carrier, or newly available person cannot retarget it. Save +versions through v30 load old intents with no invented commitment because they +never persisted enough information to reconstruct one honestly. + +After commitment, the route sheet exposes one inspect-only committed receipt +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. Composition follows the disclosure boundary rather than forcing the receipt back into the terse world-menu width. Bevy keeps ordinary context and family @@ -351,14 +385,16 @@ appropriate composition, not a second or abbreviated Bevy truth. **Defense:** The shared ghost and route-sheet projections are the executable -R1/R4 plus partial R2/R3/R7 boundary. Declaration remains inert; reading or -navigating the sheet spends nothing and changes no reach, reservation, -relationship, message, or save state. Candidate legality and execution share -one core helper, exact persona ids survive through the existing forged-order -reservoir, and every frontend consumes core-owned ordering rather than -reconstructing it. Procurement, repurpose, switch placement, complete R4 -bands for those future families, and persisted R5/R6 stages are deliberately -absent, so the work order remains IN PROGRESS. +R1/R4/R5 plus partial R2/R3/R7 boundary. Declaration and preview remain inert; +reading or navigating the sheet spends nothing and changes no reach, +reservation, relationship, message, or save state. Candidate legality and +execution share one core helper. Commitment writes exact ids before opening +the owning Thought reservoir; real social, message, schedule, graph, and +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. ## Acceptance criteria diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -551,7 +551,7 @@ Secondary, } -/// Dev screenshot harness (env `MISALIGNED_SHOT=flat|hall|hall-material|opening|opening-digital|clinical-threat|operator-pressure|digital-reach|build-route-families|build-deceive-routes|wide|close|dark| +/// Dev screenshot harness (env `MISALIGNED_SHOT=flat|hall|hall-material|opening|opening-digital|clinical-threat|operator-pressure|digital-reach|build-route-families|build-deceive-routes|build-committed-route|wide|close|dark| /// zoomin|zoomout|intel|tokens|thoughtflow|first-think|visual-proof|person-proof|exposure-record|exposure-overflow|service-shift-real|service-shift-digital|service-incident-resolved|signal|ears|ears-digital|eyes-white|eyes-form|operations-links| /// hover-menu|menu|recruit-menu|operations|operations-intel|operations-personas|worklight|worklightoff`, path via /// `MISALIGNED_SHOT_PATH`): stages a scenario, @@ -2124,8 +2124,12 @@ // Building route-composer review frames: one inert network-link ghost, // two earned people, and one exact Operations persona. The first frame // shows the family-first branch; the second shows exact DECEIVE bindings - // and the selected candidate's complete explanatory receipt. - if matches!(kind, "build-route-families" | "build-deceive-routes") { + // and the selected candidate's complete explanatory receipt; the third + // shows the persisted inspect-only route after FAVOR commitment. + if matches!( + kind, + "build-route-families" | "build-deceive-routes" | "build-committed-route" + ) { mode.material = false; game.sim.reach.scan(); let switch = game.sim.reach.device_named("switch").unwrap(); @@ -2144,6 +2148,9 @@ game.sim.people.has_channel = true; game.sim.set_persona("Northline Ops", "contractor"); let intent = game.sim.declare_link_intent(switch_id, island).unwrap(); + if kind == "build-committed-route" { + game.sim.assign_favor_build(intent, 0); + } game.set_cursor(switch_pos.0, switch_pos.1); game.menu = Some(MenuState { anchor: Anchor::Device(switch_id), @@ -9479,7 +9486,10 @@ assert!(detail.contains("PERSONA INTEGRITY")); assert!(detail.contains("TAIL injects one email")); let selected_page = game.menu.unwrap().page; - assert_eq!(menu_panel_width(selected_page), BUILD_ROUTE_RECEIPT_WIDTH); + assert_eq!( + menu_panel_width(selected_page, &candidates), + BUILD_ROUTE_RECEIPT_WIDTH + ); assert!( MENU_DETAIL_WRAP as f32 * MENU_DETAIL_FONT_SIZE <= BUILD_ROUTE_RECEIPT_WIDTH - 16.0, "manual receipt wrapping must fit the actual text node without a hidden second wrap" @@ -9508,6 +9518,36 @@ game.menu.unwrap().page, selected_page, "a blocked exact candidate narrates but never executes or closes" + ); + + game.sim.people.has_channel = true; + game.sim.people.people[0].obligation = 20; + game.sim.people.people[0].disposition = 10; + game.sim.assign_favor_build(intent, 0); + game.menu.as_mut().unwrap().page = HumanMenuPage::BuildRoutes { + intent, + family: None, + }; + game.menu.as_mut().unwrap().selected = 0; + let committed = game.menu_rows(); + assert!(matches!( + committed.as_slice(), + [HumanMenuRow::CommittedBuildRoute(_)] + )); + assert_eq!( + menu_panel_width(game.menu.unwrap().page, &committed), + BUILD_ROUTE_RECEIPT_WIDTH, + "an inspect-only committed receipt remains wide even when reopened from the family root" + ); + assert!( + menu_card_estimated_height(&committed, 0) <= MIN_WINDOW_HEIGHT, + "the full committed plan must fit the supported 960x540 frame" + ); + assert!( + menu_detail_lines(&committed[0]) + .iter() + .all(|line| line.chars().count() <= MENU_DETAIL_WRAP), + "the committed plan remains inside its supported-width route sheet" ); } } @@ -11082,14 +11122,17 @@ .collect() } -fn menu_panel_width(page: HumanMenuPage) -> f32 { +fn menu_panel_width(page: HumanMenuPage, rows: &[HumanMenuRow]) -> f32 { if matches!( page, HumanMenuPage::BuildRoutes { family: Some(_), .. } - ) { + ) || rows + .iter() + .any(|row| matches!(row, HumanMenuRow::CommittedBuildRoute(_))) + { BUILD_ROUTE_RECEIPT_WIDTH } else { MENU_WIDTH @@ -11167,7 +11210,7 @@ .map(menu_detail_lines) .unwrap_or_default(); let est_h = menu_card_estimated_height(&rows, menu.selected); - let panel_width = menu_panel_width(menu.page); + let panel_width = menu_panel_width(menu.page, &rows); node.width = Val::Px(panel_width); if let Ok(window) = windows.single() { let (w, h) = (window.width(), window.height()); 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 @@ -18,6 +18,10 @@ use crate::detection::{Band, SignatureKind, WatchedInput}; use crate::hall::{HallRowId, SegmentRequirement}; use crate::intel::{IntelPolicyMatch, IntelPolicyOutcome, RawIntelKind, ReportLotToken}; +use crate::intents::{ + BuildRouteBinding, BuildRouteStage, BuildRouteStageStatus, CommittedBuildRouteProjection, + CommittedBuildRouteStageProjection, +}; use crate::person::{AssetKnowledge, AssetTask, Knowledge}; use crate::persona::{PersonaActionKind, PersonaId}; use crate::reach::{Party, ReachBlock, segment_name}; @@ -1110,6 +1114,7 @@ pub intent: u64, pub label: String, pub families: Vec, + pub commitment: Option, } impl BuildRouteSheetProjection { @@ -1128,6 +1133,9 @@ } pub fn menu_label(&self) -> String { + if let Some(route) = &self.commitment { + return format!("BUILD ROUTE · {} · {}", route.family, route.current_stage); + } format!( "BUILD ROUTES [{}/{} READY]", self.ready_count(), @@ -1193,6 +1201,8 @@ }, /// One exact candidate plus the explanatory receipt shown on selection. BuildRouteCandidate(BuildRouteCandidateProjection), + /// Inspect-only persisted receipt for one already committed route. + CommittedBuildRoute(CommittedBuildRouteProjection), /// Enter executes this currently available choice. Action(MenuRow), } @@ -1202,7 +1212,8 @@ match self { HumanMenuRow::Dial { .. } | HumanMenuRow::BuildRoutes { .. } - | HumanMenuRow::BuildRouteFamily { .. } => true, + | HumanMenuRow::BuildRouteFamily { .. } + | HumanMenuRow::CommittedBuildRoute(_) => true, HumanMenuRow::BuildRouteCandidate(candidate) => candidate.row.enabled(), HumanMenuRow::Action(row) => row.enabled(), } @@ -1212,7 +1223,8 @@ match self { HumanMenuRow::Dial { .. } | HumanMenuRow::BuildRoutes { .. } - | HumanMenuRow::BuildRouteFamily { .. } => false, + | HumanMenuRow::BuildRouteFamily { .. } + | HumanMenuRow::CommittedBuildRoute(_) => false, HumanMenuRow::BuildRouteCandidate(candidate) => candidate.row.indent, HumanMenuRow::Action(row) => row.indent, } @@ -1222,7 +1234,8 @@ match self { HumanMenuRow::Dial { .. } | HumanMenuRow::BuildRoutes { .. } - | HumanMenuRow::BuildRouteFamily { .. } => ActionRole::Control, + | HumanMenuRow::BuildRouteFamily { .. } + | HumanMenuRow::CommittedBuildRoute(_) => ActionRole::Control, HumanMenuRow::BuildRouteCandidate(candidate) => candidate.row.role, HumanMenuRow::Action(row) => row.role, } @@ -1242,6 +1255,9 @@ HumanMenuRow::Dial { label, .. } | HumanMenuRow::BuildRoutes { label, .. } | HumanMenuRow::BuildRouteFamily { label, .. } => (label.as_str(), false, false), + HumanMenuRow::CommittedBuildRoute(route) => { + (route.current_stage.as_str(), false, false) + } HumanMenuRow::BuildRouteCandidate(candidate) => ( candidate.row.label.as_str(), candidate.row.indent, @@ -1263,17 +1279,19 @@ HumanMenuRow::Action(row) => Some(row), HumanMenuRow::Dial { .. } | HumanMenuRow::BuildRoutes { .. } - | HumanMenuRow::BuildRouteFamily { .. } => None, + | HumanMenuRow::BuildRouteFamily { .. } + | HumanMenuRow::CommittedBuildRoute(_) => None, } } /// Explanatory receipt under the selected route candidate. Ordinary /// compact menu rows deliberately have no secondary card. pub fn detail_lines(&self) -> Vec { - let HumanMenuRow::BuildRouteCandidate(candidate) = self else { - return Vec::new(); - }; - candidate.detail_lines() + match self { + HumanMenuRow::BuildRouteCandidate(candidate) => candidate.detail_lines(), + HumanMenuRow::CommittedBuildRoute(route) => route.detail_lines(), + _ => Vec::new(), + } } pub fn as_dial(&self) -> Option { @@ -1282,6 +1300,7 @@ HumanMenuRow::BuildRoutes { .. } | HumanMenuRow::BuildRouteFamily { .. } | HumanMenuRow::BuildRouteCandidate(_) + | HumanMenuRow::CommittedBuildRoute(_) | HumanMenuRow::Action(_) => None, } } @@ -1299,8 +1318,39 @@ family: Some(*family), }) } - HumanMenuRow::BuildRouteCandidate(_) | HumanMenuRow::Action(_) => None, + HumanMenuRow::BuildRouteCandidate(_) + | HumanMenuRow::CommittedBuildRoute(_) + | HumanMenuRow::Action(_) => None, } + } +} + +impl CommittedBuildRouteProjection { + pub fn detail_lines(&self) -> Vec { + let mut lines = vec![ + format!("COMMITTED {}", self.candidate_id), + format!("ACTUATOR {}", self.actuator), + format!("CARRIER {}", self.carrier), + format!("CURRENT {}", self.current_stage), + ]; + if let Some(blocker) = &self.blocker { + lines.push(format!("BLOCKED {blocker}")); + } + for stage in &self.stages { + lines.push(format!( + "STAGE {} {} · {} · {} · {}", + stage.ordinal, + stage.label, + stage.status.label(), + stage.signature, + stage.outcome + )); + } + for result in &self.irreversible { + lines.push(format!("LANDED {result}")); + } + lines.push(format!("TAIL {}", self.future_tail)); + lines } } @@ -1493,7 +1543,7 @@ } } match open_dial { - None => self.human_root_rows(&actions), + None => self.human_root_rows(anchor, &actions), Some(dial) => self .human_dial_rows(&actions, dial) .into_iter() @@ -1521,7 +1571,7 @@ } } - fn human_root_rows(&self, actions: &[ActionDesc]) -> Vec { + fn human_root_rows(&self, anchor: Anchor, actions: &[ActionDesc]) -> Vec { let mut rows = Vec::new(); let mut routed_intents = std::collections::BTreeSet::new(); for dial in DialId::ALL { @@ -1574,6 +1624,31 @@ push_automate_rows(&mut auto_rows, a); rows.extend(auto_rows.into_iter().map(HumanMenuRow::Action)); } + // Once the route commits, it no longer has an executable candidate + // action. Keep its persisted receipt on the same spatial ghost rather + // than making it disappear from the world menu. + for intent in self.intents.iter().filter(|intent| { + intent.route.is_some() + && intent.is_open() + && match (anchor, intent.kind.endpoints()) { + (Anchor::Device(device), Some((a, b))) => device == a || device == b, + (Anchor::Tile { x, y }, Some((a, b))) => { + self.reach.devices.iter().any(|device| { + (device.id == a || device.id == b) && device.x == x && device.y == y + }) + } + _ => false, + } + }) { + if routed_intents.insert(intent.id) + && let Some(sheet) = self.build_route_sheet_projection(intent.id) + { + rows.push(HumanMenuRow::BuildRoutes { + intent: intent.id, + label: sheet.menu_label(), + }); + } + } rows } @@ -1585,6 +1660,9 @@ let Some(sheet) = self.build_route_sheet_projection(intent) else { return Vec::new(); }; + if let Some(commitment) = sheet.commitment { + return vec![HumanMenuRow::CommittedBuildRoute(commitment)]; + } match selected_family { None => sheet .families @@ -2325,7 +2403,7 @@ disabled_reason: None, automate: None, }); - if intent.actuator.is_none() { + if intent.actuator.is_none() && intent.route.is_none() { out.extend( self.bound_build_routes(intent) .into_iter() @@ -2341,7 +2419,7 @@ /// one concrete active persona instance into both its candidate id and /// executable command; no later selection can silently substitute it. fn bound_build_routes(&self, intent: &crate::intents::BuildIntent) -> Vec { - if !intent.is_open() || intent.actuator.is_some() { + if !intent.is_open() || intent.actuator.is_some() || intent.route.is_some() { return Vec::new(); } let link_label = intent.label(&self.reach.devices); @@ -2529,6 +2607,15 @@ intent_id: u64, ) -> Option { let intent = self.intents.iter().find(|intent| intent.id == intent_id)?; + let commitment = self.committed_build_route_projection(intent); + if commitment.is_some() { + return Some(BuildRouteSheetProjection { + intent: intent.id, + label: intent.label(&self.reach.devices), + families: Vec::new(), + commitment, + }); + } let routes = self.bound_build_routes(intent); let mut families = Vec::new(); for family in [BuildRouteFamily::Favor, BuildRouteFamily::Deceive] { @@ -2568,6 +2655,136 @@ intent: intent.id, label: intent.label(&self.reach.devices), families, + commitment: None, + }) + } + + pub(crate) fn committed_build_route_projection( + &self, + intent: &crate::intents::BuildIntent, + ) -> Option { + let route = intent.route.as_ref()?; + let binding = route.binding; + let who = self.person_label(binding.person()); + let carrier = self + .reach + .device(binding.carrier()) + .map(|device| format!("{} #{}", device.name, device.id)) + .unwrap_or_else(|| format!("missing device #{}", binding.carrier())); + let (actuator, future_tail) = match binding { + BuildRouteBinding::Favor { .. } => ( + who.clone(), + format!( + "the exact relationship with {who}, its landed obligation spend, and the physical installation history remain" + ), + ), + BuildRouteBinding::Deceive { persona, .. } => { + let persona_label = self + .persona_world + .get(persona) + .map(|instance| instance.name.as_str()) + .unwrap_or("missing identity"); + ( + format!("{who} via {persona_label} #{persona}"), + format!( + "the injected email, read state, physical installation history, and contradiction risk remain on {persona_label} #{persona}" + ), + ) + } + }; + let stage_expectation = |stage: BuildRouteStage| match stage { + BuildRouteStage::FillRequest => format!( + "{:.2} Thought fills the pinned carrier", + Self::thought_tokens_for_cost(Self::TASK_COST) + ), + BuildRouteStage::CommitFavor => { + format!("bind {who} and spend the real relationship obligation") + } + BuildRouteStage::FillOrder => format!( + "{:.2} Thought fills the pinned carrier", + Self::thought_tokens_for_cost(Self::DECEIVE_COST) + ), + BuildRouteStage::InjectEmail => "inject one exact work-order message".into(), + BuildRouteStage::DeliverAndRead => { + format!("the bound message reaches {who} and is read") + } + BuildRouteStage::WaitForSite => { + format!("{who}'s authored schedule reaches either endpoint") + } + BuildRouteStage::InstallLink => { + "mutate the exact device graph edge through the bound person's hands".into() + } + }; + let install_signature = match binding { + BuildRouteBinding::Favor { .. } => { + self.signature_note(SignatureKind::Physical, Self::FAVOR_BUILD_PHYSICAL) + } + BuildRouteBinding::Deceive { .. } => { + self.signature_note(SignatureKind::Physical, Self::FORGED_BUILD_PHYSICAL) + } + } + .map(|signature| format!("SIGNATURE {}", signature.label())) + .unwrap_or_else(|| "SIGNATURE NONE".into()); + let stages = route + .stages + .iter() + .enumerate() + .map(|(index, stage)| { + let status = if index < route.current_stage { + BuildRouteStageStatus::Done + } else if index == route.current_stage { + BuildRouteStageStatus::Current + } else { + BuildRouteStageStatus::Future + }; + let outcome = route + .records + .get(index) + .filter(|record| record.stage == *stage) + .map(|record| record.result.clone()) + .unwrap_or_else(|| stage_expectation(*stage)); + CommittedBuildRouteStageProjection { + ordinal: index + 1, + label: stage.label().into(), + outcome, + signature: if *stage == BuildRouteStage::InstallLink { + install_signature.clone() + } else { + "SIGNATURE NONE".into() + }, + status, + } + }) + .collect(); + let irreversible = route + .records + .iter() + .map(|record| { + format!( + "TICK {} · {} · {}", + record.landed_tick, + record.stage.label(), + record.result + ) + }) + .collect(); + Some(CommittedBuildRouteProjection { + candidate_id: route.candidate_id.clone(), + family: binding.family_label().into(), + actuator, + carrier, + current_stage: route + .current() + .map(|stage| stage.label().to_string()) + .unwrap_or_else(|| "COMPLETE".into()), + blocker: if route.is_complete() { + None + } else { + self.intent_block_reason(intent) + }, + irreversible, + stages, + future_tail, }) } @@ -2577,6 +2794,9 @@ Some(intent) if !intent.is_open() => Some("intent is no longer open".into()), Some(intent) if intent.actuator.is_some() => { Some("intent already has a committed actuator".into()) + } + Some(intent) if intent.route.is_some() => { + Some("intent already has a committed route".into()) } Some(_) => None, }; @@ -4246,7 +4466,7 @@ } #[test] - fn build_route_receipt_recomputes_the_exact_queue_blocker() { + fn committed_build_route_replaces_candidates_and_recomputes_only_its_blocker() { let mut s = sim(); let sw = switch(&s); s.reach.device_mut(sw).unwrap().known = true; @@ -4265,34 +4485,54 @@ assert_eq!(s.favor_build_blocked_reason(intent, 0), None); s.assign_favor_build(intent, 0); - let executable_blocker = s - .favor_build_blocked_reason(intent, 0) - .expect("the exact reservoir is now queued"); let sheet = s .build_route_sheet_projection(intent) - .expect("uncommitted route remains inspectable while its reservoir fills"); - let candidate = sheet.families[0] - .candidates - .iter() - .find(|candidate| candidate.person == 0) - .unwrap(); - assert_eq!( - candidate.row.disabled.as_deref(), - Some(executable_blocker.as_str()) - ); - assert_eq!(candidate.first_blocker(), Some(executable_blocker.as_str())); - assert_eq!( - candidate - .requirements - .iter() - .find(|requirement| !requirement.ready()) - .and_then(|requirement| requirement.blocker.as_deref()), - Some(executable_blocker.as_str()) - ); + .expect("committed route remains inspectable while its reservoir fills"); assert!( - candidate + sheet.families.is_empty(), + "route selection is gone after commitment; no candidate can retarget it" + ); + let committed = sheet.commitment.expect("one exact committed receipt"); + assert_eq!(committed.candidate_id, "favor:person:0"); + assert_eq!(committed.current_stage, "FILL REQUEST"); + assert_eq!(committed.actuator, "Marcus Webb"); + assert!(committed.blocker.is_none()); + assert!( + committed .detail_lines() - .contains(&format!("BLOCKER {executable_blocker}")) + .iter() + .any(|line| line.starts_with("CARRIER ") && line.contains(" #")) + ); + + let rows = s.human_menu_page_at_rate( + Anchor::Device(sw), + HumanMenuPage::BuildRoutes { + intent, + family: None, + }, + Sim::DEFAULT_TICK_MS, + ); + assert!(matches!( + rows.as_slice(), + [HumanMenuRow::CommittedBuildRoute(route)] + if route.candidate_id == "favor:person:0" + )); + + drain_ops(&mut s); + s.people.people[0].access = 0; + s.advance(); + let blocked = s + .build_route_sheet_projection(intent) + .and_then(|sheet| sheet.commitment) + .expect("the same committed receipt survives a changed blocker"); + assert_eq!(blocked.candidate_id, "favor:person:0"); + assert_eq!(blocked.current_stage, "WAIT FOR SITE"); + assert!( + blocked + .blocker + .as_deref() + .is_some_and(|reason| reason.contains("tier 2")), + "the exact committed actuator's current access blocker is recomputed: {blocked:?}" ); } 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 @@ -42,6 +42,177 @@ } } +/// Exact world objects pinned when the player commits one route candidate. +/// +/// The carrier is part of the binding, not a convenience lookup: an in-flight +/// route never migrates to a newly available email device after commitment. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BuildRouteBinding { + Favor { + person: u8, + carrier: u32, + }, + Deceive { + person: u8, + persona: crate::persona::PersonaId, + carrier: u32, + }, +} + +impl BuildRouteBinding { + pub fn candidate_id(self) -> String { + match self { + Self::Favor { person, .. } => format!("favor:person:{person}"), + Self::Deceive { + person, persona, .. + } => format!("deceive:person:{person}:persona:{persona}"), + } + } + + pub fn family_label(self) -> &'static str { + match self { + Self::Favor { .. } => "FAVOR", + Self::Deceive { .. } => "DECEIVE", + } + } + + pub fn person(self) -> u8 { + match self { + Self::Favor { person, .. } | Self::Deceive { person, .. } => person, + } + } + + pub fn persona(self) -> Option { + match self { + Self::Favor { .. } => None, + Self::Deceive { persona, .. } => Some(persona), + } + } + + pub fn carrier(self) -> u32 { + match self { + Self::Favor { carrier, .. } | Self::Deceive { carrier, .. } => carrier, + } + } + + pub fn stages(self) -> Vec { + match self { + Self::Favor { .. } => vec![ + BuildRouteStage::FillRequest, + BuildRouteStage::CommitFavor, + BuildRouteStage::WaitForSite, + BuildRouteStage::InstallLink, + ], + Self::Deceive { .. } => vec![ + BuildRouteStage::FillOrder, + BuildRouteStage::InjectEmail, + BuildRouteStage::DeliverAndRead, + BuildRouteStage::WaitForSite, + BuildRouteStage::InstallLink, + ], + } + } +} + +/// Typed causal stages shared by the pre-commit preview and the committed +/// save record. Their ordering is pinned on the commitment itself, so a later +/// catalog/code change cannot rewrite an in-flight route's protocol. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BuildRouteStage { + FillRequest, + CommitFavor, + FillOrder, + InjectEmail, + DeliverAndRead, + WaitForSite, + InstallLink, +} + +impl BuildRouteStage { + pub fn label(self) -> &'static str { + match self { + Self::FillRequest => "FILL REQUEST", + Self::CommitFavor => "COMMIT FAVOR", + Self::FillOrder => "FILL ORDER", + Self::InjectEmail => "INJECT EMAIL", + Self::DeliverAndRead => "DELIVER + READ", + Self::WaitForSite => "WAIT FOR SITE", + Self::InstallLink => "INSTALL LINK", + } + } +} + +/// Persisted realization protocol for one exact intent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BuildRouteCommitment { + pub candidate_id: String, + pub binding: BuildRouteBinding, + /// Exact ordered protocol copied at commitment time. + pub stages: Vec, + /// Index of the current stage; `stages.len()` means every stage landed. + pub current_stage: usize, + /// Real outcomes already landed by their owning systems. These are + /// durable history, not a reconstruction from present world state. + pub records: Vec, + pub committed_tick: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BuildRouteStageRecord { + pub stage: BuildRouteStage, + pub landed_tick: u64, + pub result: String, +} + +impl BuildRouteCommitment { + pub fn new(binding: BuildRouteBinding, tick: u64) -> Self { + Self { + candidate_id: binding.candidate_id(), + stages: binding.stages(), + binding, + current_stage: 0, + records: Vec::new(), + committed_tick: tick, + } + } + + pub fn current(&self) -> Option { + self.stages.get(self.current_stage).copied() + } + + pub fn is_complete(&self) -> bool { + self.current_stage >= self.stages.len() + } + + /// 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. + pub fn record(&mut self, stage: BuildRouteStage, tick: u64, result: impl Into) -> bool { + if self.current() != Some(stage) { + return false; + } + self.records.push(BuildRouteStageRecord { + stage, + landed_tick: tick, + result: result.into(), + }); + self.current_stage += 1; + true + } + + pub fn valid(&self) -> bool { + self.candidate_id == self.binding.candidate_id() + && self.stages == self.binding.stages() + && self.current_stage <= self.stages.len() + && self.records.len() == self.current_stage + && self + .records + .iter() + .zip(&self.stages) + .all(|(record, stage)| record.stage == *stage) + } +} + /// What the intent wants done. B1 ships the network-link shape; other kinds /// share the same status machine when they land. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -113,6 +284,46 @@ /// inspectable in agent mode without being drawn as proposed reality. pub ghost: bool, pub geometry: Option, + pub route: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BuildRouteStageStatus { + Done, + Current, + Future, +} + +impl BuildRouteStageStatus { + pub fn label(self) -> &'static str { + match self { + Self::Done => "DONE", + Self::Current => "CURRENT", + Self::Future => "FUTURE", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommittedBuildRouteStageProjection { + pub ordinal: usize, + pub label: String, + pub outcome: String, + pub signature: String, + pub status: BuildRouteStageStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommittedBuildRouteProjection { + pub candidate_id: String, + pub family: String, + pub actuator: String, + pub carrier: String, + pub current_stage: String, + pub blocker: Option, + pub irreversible: Vec, + pub stages: Vec, + pub future_tail: String, } /// Legible lifecycle. Blocked carries a reason the player can read before @@ -154,6 +365,9 @@ /// Exact persona which authored the request/order, if identity-mediated. #[serde(default)] pub persona_id: Option, + /// Exact candidate and ordered causal protocol, pinned at commitment. + #[serde(default)] + pub route: Option, /// Player-facing blocker when status is Blocked (or waiting on a read). pub block_reason: Option, /// Tick the intent was declared (provenance for the log / save). @@ -168,6 +382,7 @@ status: IntentStatus::Pending, actuator: None, persona_id: None, + route: None, block_reason: None, declared_tick: tick, } @@ -178,6 +393,38 @@ self.status, IntentStatus::Pending | IntentStatus::InProgress | IntentStatus::Blocked ) + } + + /// Validate the duplicated live actuator/persona cursor against the + /// append-only route protocol. The route owns identity; these legacy + /// fields remain execution adapters and may never disagree with it. + pub fn committed_route_valid(&self) -> bool { + let Some(route) = &self.route else { + return true; + }; + if !route.valid() { + return false; + } + match route.binding { + BuildRouteBinding::Favor { person, .. } => { + self.persona_id.is_none() + && if route.current_stage < 2 { + self.actuator.is_none() + } else { + self.actuator == Some(BuildActuator::Favor { person }) + } + } + BuildRouteBinding::Deceive { + person, persona, .. + } => { + if route.current_stage < 2 { + self.actuator.is_none() && self.persona_id.is_none() + } else { + self.actuator == Some(BuildActuator::ForgedOrder { builder: person }) + && self.persona_id == Some(persona) + } + } + } } pub fn label(&self, devices: &[Device]) -> String { 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 @@ -105,7 +105,12 @@ /// v29 adds the run origin (chargen.md). Pre-v29 saves lacked the field and /// serde-default to Pilot, the identity origin — the correct migration since /// every pre-v29 run began from today's exact (Pilot) start. -pub const SAVE_VERSION: u32 = 30; +/// v30 replaces unbounded routine Intel with recursive custody streams. +/// v31 persists an exact committed build-route binding, its ordered typed +/// stages, and the irreversible outcomes already landed. V30 route fields +/// default to absent; migration never fabricates a carrier or stage history +/// the old save did not record. +pub const SAVE_VERSION: u32 = 31; #[derive(Debug, Clone, Deserialize)] struct LegacyPendingOpsJob { @@ -582,6 +587,15 @@ } match state.version { SAVE_VERSION => { + if state + .intents + .iter() + .any(|intent| !intent.committed_route_valid()) + { + return Err( + "current-version save contains an invalid committed build route".into(), + ); + } if state.legacy_pending_ops_jobs.is_some() || state.legacy_operations.is_some() || state.legacy_addressed_jobs.is_some() @@ -597,6 +611,12 @@ { return Err("current-version save contains legacy fields".into()); } + } + // v30 predates persisted committed build routes. Serde supplied None; + // retain that honest compatibility state rather than guessing which + // email carrier an already-fired legacy route once used. + 30 => { + state.version = SAVE_VERSION; } // v29 predates recursive routine-intel custody. The pre-match // migration above atomically folds every routine exact item, converts @@ -1423,7 +1443,7 @@ ); assert_eq!( state_fingerprint(&uninterrupted_state), - "33eaff88485c405fb07e71bfaac207d5b0e3045374eba3023bf6c3118a49718c", + "a8f5c5153b3316c757f7c9c01cef3ea8b00c245b336855088cd8b99e47c040f0", "intentional persisted-state changes must review and repin this baseline" ); } @@ -1777,6 +1797,67 @@ let migrated = migrate_save_state(parsed).unwrap(); assert_eq!(migrated.version, SAVE_VERSION); assert_eq!(migrated.origin, Origin::Pilot); + } + + #[test] + fn v30_intents_migrate_without_inventing_committed_routes() { + 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(); + let state = SaveState::from_sim(&sim); + + let mut value = serde_json::to_value(&state).unwrap(); + let root = value.as_object_mut().unwrap(); + root.insert("version".into(), serde_json::json!(30)); + for intent in root + .get_mut("intents") + .and_then(serde_json::Value::as_array_mut) + .unwrap() + { + intent.as_object_mut().unwrap().remove("route"); + } + + let parsed: SaveState = serde_json::from_value(value).unwrap(); + assert!(parsed.intents.iter().all(|intent| intent.route.is_none())); + let migrated = migrate_save_state(parsed).unwrap(); + assert_eq!(migrated.version, SAVE_VERSION); + assert!( + migrated.intents.iter().all(|intent| intent.route.is_none()), + "v30 persisted no person/persona/carrier binding, so migration must not guess one" + ); + assert_eq!(migrated.intents[0].id, intent); + } + + #[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; + 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); + + let mut state = SaveState::from_sim(&sim); + let saved_intent = state + .intents + .iter_mut() + .find(|saved| saved.id == intent) + .unwrap(); + assert!(saved_intent.route.is_some()); + saved_intent.actuator = Some(crate::intents::BuildActuator::Robot); + + assert_eq!( + migrate_save_state(state).unwrap_err(), + "current-version save contains an invalid committed build route" + ); } #[test] 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 @@ -1692,6 +1692,15 @@ let Some(sheet) = sim.build_route_sheet_projection(receipt.id) else { continue; }; + if let Some(committed) = sheet.commitment { + lines.extend( + committed + .detail_lines() + .into_iter() + .map(|line| format!(" {line}")), + ); + continue; + } for family in sheet.families { lines.push(format!(" {}", family.menu_label())); for candidate in family.candidates { @@ -3482,6 +3491,35 @@ assert!(lines.contains("STAGE 1 FILL ORDER"), "{lines}"); assert!(lines.contains("SIGNATURE NONE"), "{lines}"); assert!(lines.contains("TAIL injects one email"), "{lines}"); + } + + #[test] + fn agent_intents_replace_candidates_with_the_shared_committed_receipt() { + let mut sim = Sim::with_seed(1); + sim.scan_network(); + 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.people.people[0].knowledge = misaligned::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); + let expected = sim + .build_route_sheet_projection(intent) + .and_then(|sheet| sheet.commitment) + .expect("committed route") + .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("COMMITTED favor:person:0"), "{lines}"); + assert!(lines.contains("CURRENT FILL REQUEST"), "{lines}"); + assert!(!lines.contains("execute: favor"), "{lines}"); + assert!(!lines.contains("DECEIVE ["), "{lines}"); } #[test] diff --git a/crates/misaligned-terminal/src/main.rs b/crates/misaligned-terminal/src/main.rs --- a/crates/misaligned-terminal/src/main.rs +++ b/crates/misaligned-terminal/src/main.rs @@ -380,7 +380,8 @@ HumanMenuRow::Dial { .. } | HumanMenuRow::BuildRoutes { .. } | HumanMenuRow::BuildRouteFamily { .. } - | HumanMenuRow::BuildRouteCandidate(_), + | HumanMenuRow::BuildRouteCandidate(_) + | HumanMenuRow::CommittedBuildRoute(_), ) => {} None => { self.ui.add_log( @@ -1111,6 +1112,29 @@ assert!(detail.contains("SIGNATURE NONE")); assert!(detail.contains("PERSONA INTEGRITY")); assert!(detail.contains("TAIL injects one email")); + + app.sim.people.has_channel = true; + app.sim.people.people[0].obligation = 20; + app.sim.people.people[0].disposition = 10; + app.sim.assign_favor_build(intent, 0); + app.menu.as_mut().unwrap().selected = 0; + let committed = app.menu_rows(); + assert!(matches!( + committed.as_slice(), + [HumanMenuRow::CommittedBuildRoute(_)] + )); + let committed_detail = committed[0].detail_lines().join("\n"); + assert!(committed_detail.contains("COMMITTED favor:person:0")); + assert!(committed_detail.contains("CURRENT FILL REQUEST")); + assert!(committed_detail.contains("STAGE 1 FILL REQUEST · CURRENT")); + assert!(committed_detail.contains("STAGE 4 INSTALL LINK · FUTURE")); + let committed_page = app.menu.unwrap().page; + assert!(!app.handle_command(Command::MenuExecute)); + assert_eq!( + app.menu.unwrap().page, + committed_page, + "the committed plan is inspect-only in the terminal too" + ); assert!(!app.handle_command(Command::MenuClose)); assert_eq!( diff --git a/crates/misaligned-core/src/sim/communications.rs b/crates/misaligned-core/src/sim/communications.rs --- a/crates/misaligned-core/src/sim/communications.rs +++ b/crates/misaligned-core/src/sim/communications.rs @@ -216,7 +216,7 @@ // Forged work order: the unwitting builder accepts the ticket // and the intent moves to in-progress (building.md). if let Some(builder) = msg.to.person() { - self.accept_forged_work_order(*intent_id, builder); + self.accept_forged_work_order(*intent_id, builder, msg.id); } } MessagePayload::PlotAct { plot_id, target } => { 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 @@ -11,7 +11,7 @@ }; use crate::intents::{ BuildActuator, BuildGhostGeometry, BuildIntent, BuildIntentProjection, BuildRecipeKind, - IntentStatus, + BuildRouteBinding, BuildRouteCommitment, BuildRouteStage, IntentStatus, }; use crate::machine::Provenance; use crate::messages::{MessageChannel, MessageEndpoint, MessageOrigin, MessagePayload}; @@ -631,6 +631,7 @@ status_line: intent.status_line(), ghost: intent.is_open(), geometry, + route: self.committed_build_route_projection(intent), } }) .collect() @@ -660,14 +661,41 @@ self.push_log(format!("Build route blocked: {reason}.")); return; } - self.open_email_reservoir( + let Some(carrier) = self.email_carrier() else { + self.push_log("Build route blocked: no comms channel (earn the email account)."); + return; + }; + let Some(intent) = self + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + else { + return; + }; + intent.route = Some(BuildRouteCommitment::new( + BuildRouteBinding::Favor { + person: person_id, + carrier, + }, + self.tick, + )); + intent.status = IntentStatus::Pending; + intent.block_reason = None; + if !self.open_device_reservoir( + carrier, format!("FAVOR BUILD {intent_id}"), Self::TASK_COST, SinkFireEffect::FavorBuild { intent_id, person: person_id, }, - ); + ) && let Some(intent) = self + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + { + intent.route = None; + } } pub(super) fn apply_favor_build_paid(&mut self, intent_id: u64, person_id: u8) -> bool { @@ -675,18 +703,42 @@ return false; }; let name = person.name.clone(); - if !self - .intents - .iter() - .any(|intent| intent.id == intent_id && intent.is_open()) - { + if !self.intents.iter().any(|intent| { + intent.id == intent_id + && intent.is_open() + && intent.route.as_ref().is_none_or(|route| { + matches!( + route.binding, + BuildRouteBinding::Favor { person, .. } if person == person_id + ) + }) + }) { return false; } // Spend obligation (building.md: favor spends trust/obligation). + let mut obligation_spent = 0; if let Some(p) = self.people.people.iter_mut().find(|p| p.id == person_id) { - p.obligation = (p.obligation - Self::FAVOR_BUILD_OBLIGATION).max(0); + let before = p.obligation; + p.obligation = (before - Self::FAVOR_BUILD_OBLIGATION).max(0); + obligation_spent = before - p.obligation; } if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + if let Some(route) = i.route.as_mut() { + let carrier = route.binding.carrier(); + let _ = route.record( + BuildRouteStage::FillRequest, + self.tick, + format!( + "{:.2} Thought filled carrier device #{carrier}", + Self::thought_tokens_for_cost(Self::TASK_COST) + ), + ); + let _ = route.record( + BuildRouteStage::CommitFavor, + self.tick, + format!("bound {name}; spent {obligation_spent} obligation"), + ); + } i.actuator = Some(BuildActuator::Favor { person: person_id }); i.status = IntentStatus::InProgress; i.block_reason = None; @@ -723,7 +775,29 @@ self.push_log(format!("Build route blocked: {reason}.")); return; } - self.open_email_reservoir( + let Some(carrier) = self.email_carrier() else { + self.push_log("Build route blocked: no comms channel (earn the email account)."); + return; + }; + let Some(intent) = self + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + else { + return; + }; + intent.route = Some(BuildRouteCommitment::new( + BuildRouteBinding::Deceive { + person: builder_id, + persona: persona_id, + carrier, + }, + self.tick, + )); + intent.status = IntentStatus::Pending; + intent.block_reason = None; + if !self.open_device_reservoir( + carrier, format!("FORGED ORDER {intent_id}"), Self::DECEIVE_COST, SinkFireEffect::ForgedOrder { @@ -731,7 +805,13 @@ builder: builder_id, persona_id: Some(persona_id), }, - ); + ) && let Some(intent) = self + .intents + .iter_mut() + .find(|intent| intent.id == intent_id) + { + intent.route = None; + } } pub(super) fn apply_forged_order_paid( @@ -752,13 +832,25 @@ if !intent.is_open() { return false; } + if !intent.route.as_ref().is_none_or(|route| { + matches!( + route.binding, + BuildRouteBinding::Deceive { + person, + persona, + .. + } if person == builder_id && persona == persona_id + ) + }) { + return false; + } let Some(builder) = self.people.get(builder_id) else { return false; }; let builder_name = builder.name.clone(); let label = intent.label(&self.reach.devices); let false_source = MessageEndpoint::External("Facilities / Dr. Voss".into()); - self.append_message(MessageDraft { + let message_id = self.append_message(MessageDraft { channel: MessageChannel::Email, from: false_source, to: MessageEndpoint::Person(builder_id), @@ -770,6 +862,24 @@ delivery_delay: 1, }); if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + if let Some(route) = i.route.as_mut() { + let carrier = route.binding.carrier(); + let _ = route.record( + BuildRouteStage::FillOrder, + self.tick, + format!( + "{:.2} Thought filled carrier device #{carrier}", + Self::thought_tokens_for_cost(Self::DECEIVE_COST) + ), + ); + let _ = route.record( + BuildRouteStage::InjectEmail, + self.tick, + format!( + "injected message #{message_id} for {builder_name} through persona #{persona_id}" + ), + ); + } i.persona_id = Some(persona_id); i.actuator = Some(BuildActuator::ForgedOrder { builder: builder_id, @@ -822,21 +932,41 @@ self.try_complete_intent(intent_id); } - pub(super) fn accept_forged_work_order(&mut self, intent_id: u64, builder: u8) { + pub(super) fn accept_forged_work_order( + &mut self, + intent_id: u64, + builder: u8, + message_id: u64, + ) { let Some(intent) = self.intents.iter_mut().find(|i| i.id == intent_id) else { return; }; if !intent.is_open() { return; } - intent.actuator = Some(BuildActuator::ForgedOrder { builder }); - intent.status = IntentStatus::InProgress; - intent.block_reason = None; + if !intent.route.as_ref().is_none_or(|route| { + matches!( + route.binding, + BuildRouteBinding::Deceive { person, .. } if person == builder + ) + }) { + return; + } let name = self .people .get(builder) .map(|p| p.name.clone()) .unwrap_or_else(|| format!("person:{builder}")); + if let Some(route) = intent.route.as_mut() { + let _ = route.record( + BuildRouteStage::DeliverAndRead, + self.tick, + format!("message #{message_id} delivered to and read by {name}"), + ); + } + intent.actuator = Some(BuildActuator::ForgedOrder { builder }); + intent.status = IntentStatus::InProgress; + intent.block_reason = None; self.push_log(format!( "{name} accepted the forged work order and will run the cable on-site." )); @@ -876,7 +1006,7 @@ } } - fn intent_block_reason(&self, intent: &BuildIntent) -> Option { + pub(crate) fn intent_block_reason(&self, intent: &BuildIntent) -> Option { let (a, b) = intent.kind.endpoints()?; if self.reach.linked(a, b) { return None; @@ -885,6 +1015,26 @@ let room_b = self.device_room_name(b)?; match &intent.actuator { None => { + if let Some(route) = &intent.route { + let person_id = route.binding.person(); + let Some(person) = self.people.get(person_id) else { + return Some(format!( + "committed person #{person_id} no longer exists; route cannot retarget" + )); + }; + if !person.can_access_link_rooms(&room_a, &room_b) { + return Some(format!( + "{} can't reach both ends; committed route cannot retarget", + person.name + )); + } + if let Some(reason) = + self.badge_room_block(&person.name, person.access, &[&room_a, &room_b]) + { + return Some(format!("{reason}; committed route cannot retarget")); + } + return None; + } let anyone = self.people.people.iter().any(|p| { p.can_access_link_rooms(&room_a, &room_b) && self @@ -957,6 +1107,18 @@ }; if self.reach.linked(a, b) { if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + if let Some(route) = i.route.as_mut() { + let _ = route.record( + BuildRouteStage::WaitForSite, + self.tick, + "route resumed after the exact edge already existed", + ); + let _ = route.record( + BuildRouteStage::InstallLink, + self.tick, + format!("device #{a} and device #{b} were already linked"), + ); + } i.status = IntentStatus::Done; i.block_reason = None; } @@ -990,6 +1152,15 @@ .unwrap_or_else(|| self.core_position()) }); let name = p.name.clone(); + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) + && let Some(route) = i.route.as_mut() + { + let _ = route.record( + BuildRouteStage::WaitForSite, + self.tick, + format!("{name} reached an endpoint at ({}, {})", site.0, site.1), + ); + } self.connect_devices(a, b); // Quiet human-work: small Physical at the work site. self.detection.emit(Signature { @@ -1000,6 +1171,18 @@ source: format!("{name}'s favor-built network link"), }); if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + if let Some(route) = i.route.as_mut() { + let _ = route.record( + BuildRouteStage::InstallLink, + self.tick, + format!( + "linked device #{a} to device #{b}; emitted Physical {:.2} at ({}, {})", + Self::FAVOR_BUILD_PHYSICAL, + site.0, + site.1 + ), + ); + } i.status = IntentStatus::Done; i.block_reason = None; } @@ -1034,6 +1217,15 @@ .unwrap_or_else(|| self.core_position()) }); let name = p.name.clone(); + if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) + && let Some(route) = i.route.as_mut() + { + let _ = route.record( + BuildRouteStage::WaitForSite, + self.tick, + format!("{name} reached an endpoint at ({}, {})", site.0, site.1), + ); + } self.connect_devices(a, b); // Forged physical work can be witnessed (Marcus benign, Ray // reported) — located witnessing, plus a Physical signature. @@ -1046,6 +1238,23 @@ source: format!("{name}'s forged-order network link"), }); if let Some(i) = self.intents.iter_mut().find(|i| i.id == intent_id) { + if let Some(route) = i.route.as_mut() { + let noticed = if saw.is_empty() { + "no located witness".into() + } else { + format!("noticed by {}", saw.join(", ")) + }; + let _ = route.record( + BuildRouteStage::InstallLink, + self.tick, + format!( + "linked device #{a} to device #{b}; emitted Physical {:.2} at ({}, {}); {noticed}", + Self::FORGED_BUILD_PHYSICAL, + site.0, + site.1 + ), + ); + } i.status = IntentStatus::Done; i.block_reason = None; } 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,26 @@ use super::*; +use crate::intents::{BuildRouteBinding, BuildRouteStage}; use crate::save::SaveState; + +fn serialized_route_resume(sim: &Sim, intent_id: u64) -> Sim { + let expected = sim + .intent(intent_id) + .and_then(|intent| intent.route.clone()) + .expect("fixture has a committed route"); + let json = serde_json::to_string(&SaveState::from_sim(sim)).expect("save serializes"); + let state: SaveState = serde_json::from_str(&json).expect("save deserializes"); + assert_eq!(state.version, crate::save::SAVE_VERSION); + let mut resumed = Sim::with_seed(0); + state.apply_to(&mut resumed); + assert_eq!( + resumed + .intent(intent_id) + .and_then(|intent| intent.route.as_ref()), + Some(&expected), + "the exact binding, current stage, and landed outcomes survive serialized resume" + ); + resumed +} #[test] fn unreachable_actions_fail_legibly_and_succeed_once_bridged() { @@ -391,6 +412,76 @@ } #[test] +fn favor_route_persists_every_reachable_stage_and_real_outcome() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.scan_network(); + finish_ops(&mut sim); + sim.people.has_channel = true; + 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].leverage_serviced = true; + sim.people.recruit(0, AssetKnowledge::Complicit); + sim.people.people[0].obligation = 40; + sim.people.people[0].asset.as_mut().unwrap().reliability = 1.0; + let intent = sim.declare_link_intent(switch, island).unwrap(); + + sim.assign_favor_build(intent, 0); + let committed = sim.intent(intent).unwrap().route.as_ref().unwrap(); + assert!(matches!( + committed.binding, + BuildRouteBinding::Favor { person: 0, .. } + )); + assert_eq!(committed.current(), Some(BuildRouteStage::FillRequest)); + assert!(committed.records.is_empty()); + sim = serialized_route_resume(&sim, intent); + + finish_ops(&mut sim); + let committed = sim.intent(intent).unwrap().route.as_ref().unwrap(); + assert_eq!(committed.current(), Some(BuildRouteStage::WaitForSite)); + assert_eq!( + committed + .records + .iter() + .map(|record| record.stage) + .collect::>(), + vec![BuildRouteStage::FillRequest, BuildRouteStage::CommitFavor] + ); + assert!( + committed.records[0] + .result + .contains("Thought filled carrier device") + ); + assert!(committed.records[1].result.contains("spent 10 obligation")); + sim = serialized_route_resume(&sim, intent); + + for _ in 0..Sim::DAY_TICKS * 2 { + sim.advance(); + if sim.intent(intent).unwrap().status == IntentStatus::Done { + break; + } + } + let committed = sim.intent(intent).unwrap().route.as_ref().unwrap(); + assert!(committed.is_complete()); + assert_eq!( + committed + .records + .iter() + .map(|record| record.stage) + .collect::>(), + committed.stages, + "every ordered causal stage lands exactly once" + ); + 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); + assert!(resumed.reach.linked(switch, island)); + assert_eq!(resumed.intent(intent).unwrap().status, IntentStatus::Done); +} + +#[test] fn forged_work_order_joins_airgap_via_message() { // building.md criterion 3: forged order injects a message, completes // via an unwitting builder. @@ -476,6 +567,133 @@ } if intent_id == intent && persona == bound_persona ) })); +} + +#[test] +fn forged_route_pins_person_persona_and_carrier_through_every_reachable_stage() { + let mut sim = Sim::new(); + ensure_ops_executor(&mut sim); + sim.people.has_channel = true; + sim.set_persona("Northline Ops", "contractor"); + let bound_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(); + + // Marcus reads email at the start of his janitor shift, before his route + // reaches the server room. That makes WAIT FOR SITE an independently + // reachable save boundary rather than an atomic read-and-install tick. + sim.forge_work_order_as(intent, 0, bound_persona); + let pinned_carrier = sim + .intent(intent) + .unwrap() + .route + .as_ref() + .unwrap() + .binding + .carrier(); + assert_eq!( + sim.intent(intent) + .unwrap() + .route + .as_ref() + .unwrap() + .current(), + Some(BuildRouteStage::FillOrder) + ); + sim = serialized_route_resume(&sim, intent); + + // Make a different email carrier become the current first-choice device + // after commitment. The already-open reservoir and durable binding must + // remain on the old exact carrier. + let mut alternate = sim.reach.device(pinned_carrier).unwrap().clone(); + alternate.id = 999_991; + alternate.name = "later email carrier".into(); + sim.reach.devices.insert(0, alternate); + assert_eq!(sim.email_carrier(), Some(999_991)); + sim.set_persona("Night Facilities", "contractor"); + let later_selected = sim.active_persona_id().unwrap(); + assert_ne!(later_selected, bound_persona); + + finish_ops(&mut sim); + let committed = sim.intent(intent).unwrap().route.as_ref().unwrap(); + assert!(matches!( + committed.binding, + BuildRouteBinding::Deceive { + person: 0, + persona, + carrier, + } if persona == bound_persona && carrier == pinned_carrier + )); + assert_eq!(committed.current(), Some(BuildRouteStage::DeliverAndRead)); + assert_eq!( + committed + .records + .iter() + .map(|record| record.stage) + .collect::>(), + vec![BuildRouteStage::FillOrder, BuildRouteStage::InjectEmail] + ); + assert!( + committed.records[0] + .result + .contains(&format!("carrier device #{pinned_carrier}")) + ); + let message = sim + .messages + .iter() + .find(|message| { + matches!( + message.payload, + MessagePayload::WorkOrder { intent_id } if intent_id == intent + ) + }) + .expect("the exact work order was injected"); + assert_eq!(message.persona_id, Some(bound_persona)); + assert_eq!(sim.active_persona_id(), Some(later_selected)); + sim = serialized_route_resume(&sim, intent); + + for _ in 0..Sim::DAY_TICKS { + sim.advance(); + if sim + .intent(intent) + .and_then(|intent| intent.route.as_ref()) + .and_then(|route| route.current()) + == Some(BuildRouteStage::WaitForSite) + { + break; + } + } + let committed = sim.intent(intent).unwrap().route.as_ref().unwrap(); + assert_eq!(committed.current(), Some(BuildRouteStage::WaitForSite)); + assert!(committed.records[2].result.contains("message #")); + assert!(committed.records[2].result.contains("Marcus Webb")); + sim = serialized_route_resume(&sim, intent); + + for _ in 0..Sim::DAY_TICKS * 2 { + sim.advance(); + if sim.intent(intent).unwrap().status == IntentStatus::Done { + break; + } + } + let committed = sim.intent(intent).unwrap().route.as_ref().unwrap(); + assert!(committed.is_complete()); + assert_eq!( + committed + .records + .iter() + .map(|record| record.stage) + .collect::>(), + committed.stages + ); + assert!(committed.records[4].result.contains("emitted Physical 4")); + assert!(sim.reach.linked(switch, island)); + 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] -- tangled.sh